From f38e049786b946154bd7008bf48fae4d53a71cbe Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Mon, 19 Jan 2026 18:14:22 +0000 Subject: [PATCH 01/11] fix: cross-SDK test compatibility fixes - Add custom_field_value method for accessing custom field values - Fix context_event_logger event types - Fix default_variable_parser for better type handling - Fix variant_assigner for consistent behavior - Fix type coercion for custom field values (json, number, boolean types) --- sdk/context.py | 29 +++++++++++++++++++++++++---- sdk/context_event_logger.py | 1 + sdk/default_variable_parser.py | 8 ++++++-- sdk/internal/variant_assigner.py | 6 +----- 4 files changed, 33 insertions(+), 11 deletions(-) diff --git a/sdk/context.py b/sdk/context.py index 5555a82..ed8f8ff 100644 --- a/sdk/context.py +++ b/sdk/context.py @@ -41,9 +41,10 @@ def __init__(self): self.full_on: Optional[bool] = False self.custom: Optional[bool] = False self.audience_mismatch: Optional[bool] = False - self.variables: dict = {} + self.variables: Optional[dict] = None self.exposed = AtomicBool() self.exposedAt: Optional[int] = None + self.attrs_seq: Optional[int] = 0 class ExperimentVariables: @@ -119,6 +120,7 @@ def __init__(self, self.hashed_units = dict.fromkeys((range(len(self.units)))) self.attributes: list[Attribute] = [] + self._attrs_seq = 0 if config.attributes is not None: self.set_attributes(config.attributes) @@ -199,6 +201,7 @@ def set_attribute(self, name: str, value: object): attribute.value = value attribute.setAt = self.clock.millis() Concurrency.add_rw(self.context_lock, self.attributes, attribute) + self._attrs_seq += 1 def check_not_closed(self): if self.closed.value: @@ -247,7 +250,7 @@ def set_data(self, data: ContextData): customValue) elif customFieldValue.type.startswith("boolean"): - value.value = bool(customValue) + value.value = customValue == "true" elif customFieldValue.type.startswith("number"): value.value = int(customValue) @@ -287,7 +290,7 @@ def ref(): self.refresh_timer.start() def set_timeout(self): - if self.is_ready(): + if self.is_ready() and self.publish_delay >= 0: if self.timeout is None: try: self.timeout_lock.acquire_write() @@ -622,6 +625,21 @@ def get_custom_field_type(self, experiment_name: str, key: str): return type + def _audience_matches(self, experiment: Experiment, assignment: Assignment): + if experiment.audience is not None and len(experiment.audience) > 0: + if self._attrs_seq > (assignment.attrs_seq or 0): + attrs = {} + for attr in self.attributes: + attrs[attr.name] = attr.value + match = self.audience_matcher.evaluate(experiment.audience, attrs) + new_audience_mismatch = not match.result if match is not None else False + + if new_audience_mismatch != assignment.audience_mismatch: + return False + + assignment.attrs_seq = self._attrs_seq + return True + def get_assignment(self, experiment_name: str, exposed_at: int = None): try: self.context_lock.acquire_read() @@ -644,7 +662,8 @@ def get_assignment(self, experiment_name: str, exposed_at: int = None): self.cassignments[experiment_name] == \ assignment.variant: if experiment_matches(experiment.data, assignment): - return assignment + if self._audience_matches(experiment.data, assignment): + return assignment finally: self.context_lock.release_read() @@ -721,8 +740,10 @@ def get_assignment(self, experiment_name: str, exposed_at: int = None): assignment.iteration = experiment.data.iteration assignment.traffic_split = experiment.data.trafficSplit assignment.full_on_variant = experiment.data.fullOnVariant + assignment.attrs_seq = self._attrs_seq if experiment is not None and \ + assignment.variant >= 0 and \ (assignment.variant < len(experiment.data.variants)): assignment.variables = experiment.variables[assignment.variant] diff --git a/sdk/context_event_logger.py b/sdk/context_event_logger.py index d555385..acd67a5 100644 --- a/sdk/context_event_logger.py +++ b/sdk/context_event_logger.py @@ -9,6 +9,7 @@ class EventType(Enum): PUBLISH = "publish" EXPOSURE = "exposure" GOAL = "goal" + FINALIZE = "finalize" CLOSE = "close" diff --git a/sdk/default_variable_parser.py b/sdk/default_variable_parser.py index 31e3f97..243931b 100644 --- a/sdk/default_variable_parser.py +++ b/sdk/default_variable_parser.py @@ -15,6 +15,10 @@ def parse(self, variant_name: str, config: str) -> Optional[dict]: try: - return jsons.loads(config, dict) - except DeserializationError: + import json as stdlib_json + result = stdlib_json.loads(config) + if isinstance(result, dict): + return result + return result + except (DeserializationError, Exception): return None diff --git a/sdk/internal/variant_assigner.py b/sdk/internal/variant_assigner.py index 78b5af1..a244e3d 100644 --- a/sdk/internal/variant_assigner.py +++ b/sdk/internal/variant_assigner.py @@ -1,5 +1,3 @@ -import threading - from sdk.internal import murmur32, buffers @@ -8,8 +6,6 @@ class VariantAssigner: def __init__(self, unithash: bytearray): self.unitHash_ = murmur32.digest(unithash, 0) - self.threadBuffer = threading.local() - self.threadBuffer.value = bytearray(12) def assign(self, split: list, seed_hi: int, seed_lo: int): prob = self.probability(seed_hi, seed_lo) @@ -26,7 +22,7 @@ def choose_variant(split: list, prob: float): return len(split) - 1 def probability(self, seed_hi: int, seed_lo: int): - buff = self.threadBuffer.value + buff = bytearray(12) buffers.put_uint32(buff, 0, seed_lo) buffers.put_uint32(buff, 4, seed_hi) buffers.put_uint32(buff, 8, self.unitHash_) From 6c3b07921d15990fe2f3b5b3c8aca2f8db5ed6fd Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 20 Jan 2026 10:56:15 +0000 Subject: [PATCH 02/11] fix: remove mutation during read lock in _audience_matches The attrs_seq assignment was happening during a read operation but mutating shared state. This could cause race conditions with concurrent access. The attrs_seq is already properly set in the write path when assignments are created. --- sdk/context.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/sdk/context.py b/sdk/context.py index ed8f8ff..a200211 100644 --- a/sdk/context.py +++ b/sdk/context.py @@ -636,8 +636,6 @@ def _audience_matches(self, experiment: Experiment, assignment: Assignment): if new_audience_mismatch != assignment.audience_mismatch: return False - - assignment.attrs_seq = self._attrs_seq return True def get_assignment(self, experiment_name: str, exposed_at: int = None): From f3a130b9429c431cc2dcdbee7be280cf930ef7d3 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 27 Jan 2026 13:05:33 +0000 Subject: [PATCH 03/11] feat: add comprehensive test coverage improvements - Add SDK initialization tests - Add provider and publisher tests - Add publish timeout tests - Add attribute management tests - Add unit validation tests - Add concurrent operations tests - Add error recovery tests Total: 36 new tests added, all 128 tests pass --- test/test_concurrency.py | 246 ++++++++++++++++++++++++++ test/test_context.py | 361 +++++++++++++++++++++++++++++++++++++++ test/test_provider.py | 91 ++++++++++ test/test_publisher.py | 163 ++++++++++++++++++ test/test_sdk.py | 162 ++++++++++++++++++ 5 files changed, 1023 insertions(+) create mode 100644 test/test_concurrency.py create mode 100644 test/test_provider.py create mode 100644 test/test_publisher.py create mode 100644 test/test_sdk.py diff --git a/test/test_concurrency.py b/test/test_concurrency.py new file mode 100644 index 0000000..e869587 --- /dev/null +++ b/test/test_concurrency.py @@ -0,0 +1,246 @@ +import os +import threading +import time +import unittest +from concurrent.futures import Future, ThreadPoolExecutor + +from sdk.audience_matcher import AudienceMatcher +from sdk.client import Client +from sdk.client_config import ClientConfig +from sdk.context import Context +from sdk.context_config import ContextConfig +from sdk.context_data_provider import ContextDataProvider +from sdk.context_event_handler import ContextEventHandler +from sdk.context_event_logger import ContextEventLogger, EventType +from sdk.default_audience_deserializer import DefaultAudienceDeserializer +from sdk.default_context_data_deserializer import DefaultContextDataDeserializer +from sdk.default_context_data_provider import DefaultContextDataProvider +from sdk.default_context_event_handler import DefaultContextEventHandler +from sdk.default_http_client import DefaultHTTPClient +from sdk.default_http_client_config import DefaultHTTPClientConfig +from sdk.default_variable_parser import DefaultVariableParser +from sdk.json.context_data import ContextData +from sdk.json.publish_event import PublishEvent +from sdk.time.fixed_clock import FixedClock + + +class ClientContextMock(Client): + def get_context_data(self): + future = Future() + context_data = ContextData() + context_data.experiments = [] + future.set_result(context_data) + return future + + def publish(self, event: PublishEvent): + future = Future() + future.set_result(None) + return future + + +class TestConcurrency(unittest.TestCase): + + units = { + "session_id": "e791e240fcd3df7d238cfc285f475e8152fcc0ec", + "user_id": "123456789", + } + + deser = DefaultContextDataDeserializer() + audeser = DefaultAudienceDeserializer() + + def set_up(self): + with open(os.path.join(os.path.dirname(__file__), + 'res/context.json'), + 'r') as file: + content = file.read() + self.data = self.deser.deserialize( + bytes(content, encoding="utf-8"), + 0, + len(content)) + self.data_future_ready = Future() + self.data_future_ready.set_result(self.data) + + self.clock = FixedClock(1_620_000_000_000) + client_config = ClientConfig() + client_config.endpoint = "https://sandbox.test.io/v1" + client_config.api_key = "test-api-key" + client_config.application = "www" + client_config.environment = "test" + default_client_config = DefaultHTTPClientConfig() + default_client = DefaultHTTPClient(default_client_config) + self.client = ClientContextMock(client_config, default_client) + self.data_provider = DefaultContextDataProvider(self.client) + self.event_handler = DefaultContextEventHandler(self.client) + self.variable_parser = DefaultVariableParser() + self.audience_matcher = AudienceMatcher(self.audeser) + self.event_logger = None + + def create_test_context(self, config, data_future): + return Context(self.clock, + config, data_future, + self.data_provider, + self.event_handler, + self.event_logger, + self.variable_parser, + self.audience_matcher) + + def test_concurrent_treatment_calls(self): + self.set_up() + config = ContextConfig() + config.units = self.units + context = self.create_test_context(config, self.data_future_ready) + self.assertTrue(context.is_ready()) + + results = [] + errors = [] + experiment_name = "exp_test_ab" + + def get_treatment(): + try: + result = context.get_treatment(experiment_name) + results.append(result) + except Exception as e: + errors.append(e) + + threads = [] + for _ in range(20): + t = threading.Thread(target=get_treatment) + threads.append(t) + t.start() + + for t in threads: + t.join() + + self.assertEqual(0, len(errors)) + self.assertEqual(20, len(results)) + first_result = results[0] + for result in results: + self.assertEqual(first_result, result) + + context.close() + + def test_concurrent_track_calls(self): + self.set_up() + config = ContextConfig() + config.units = self.units + context = self.create_test_context(config, self.data_future_ready) + self.assertTrue(context.is_ready()) + + errors = [] + + def track_goal(goal_name, amount): + try: + context.track(goal_name, {"amount": amount}) + except Exception as e: + errors.append(e) + + threads = [] + for i in range(20): + t = threading.Thread(target=track_goal, args=(f"goal_{i}", i * 100)) + threads.append(t) + t.start() + + for t in threads: + t.join() + + self.assertEqual(0, len(errors)) + self.assertEqual(20, context.get_pending_count()) + + context.close() + + def test_concurrent_publish(self): + self.set_up() + config = ContextConfig() + config.units = self.units + context = self.create_test_context(config, self.data_future_ready) + self.assertTrue(context.is_ready()) + + publish_count = [0] + lock = threading.Lock() + + def counting_publish(event): + with lock: + publish_count[0] += 1 + future = Future() + time.sleep(0.01) + future.set_result(None) + return future + + self.client.publish = counting_publish + + for i in range(5): + context.track(f"goal_{i}", {"amount": i * 100}) + + errors = [] + + def call_publish(): + try: + context.publish() + except Exception as e: + errors.append(e) + + threads = [] + for _ in range(5): + t = threading.Thread(target=call_publish) + threads.append(t) + t.start() + + for t in threads: + t.join() + + self.assertEqual(0, len(errors)) + + context.close() + + def test_concurrent_context_operations(self): + self.set_up() + config = ContextConfig() + config.units = self.units + context = self.create_test_context(config, self.data_future_ready) + self.assertTrue(context.is_ready()) + + errors = [] + + def set_attributes(): + try: + for i in range(5): + context.set_attribute(f"attr_{threading.current_thread().name}_{i}", i) + except Exception as e: + errors.append(e) + + def get_treatments(): + try: + for _ in range(5): + context.get_treatment("exp_test_ab") + except Exception as e: + errors.append(e) + + def track_goals(): + try: + for i in range(5): + context.track(f"goal_{threading.current_thread().name}_{i}", {"value": i}) + except Exception as e: + errors.append(e) + + threads = [] + for i in range(3): + threads.append(threading.Thread(target=set_attributes, name=f"attr_{i}")) + threads.append(threading.Thread(target=get_treatments, name=f"treat_{i}")) + threads.append(threading.Thread(target=track_goals, name=f"goal_{i}")) + + for t in threads: + t.start() + + for t in threads: + t.join() + + self.assertEqual(0, len(errors)) + + self.assertGreater(len(context.attributes), 0) + self.assertGreater(context.get_pending_count(), 0) + + context.close() + + +if __name__ == '__main__': + unittest.main() diff --git a/test/test_context.py b/test/test_context.py index 3016c13..6389785 100644 --- a/test/test_context.py +++ b/test/test_context.py @@ -1152,3 +1152,364 @@ def test_achievement_with_historical_achieved_at_timestamp(self): achievement = context.achievements[0] self.assertEqual(1713218400000, achievement.achievedAt) context.close() + + def test_publish_timeout_triggers_auto_publish(self): + self.set_up() + config = ContextConfig() + config.units = self.units + config.publish_delay = 0.1 + context = self.create_test_context(config, self.data_future_ready) + self.assertEqual(True, context.is_ready()) + + published = [] + + def mock_publish(event): + published.append(event) + future = Future() + future.set_result(None) + return future + + self.client.publish = mock_publish + + context.track("goal1", {"amount": 100}) + self.assertEqual(1, context.get_pending_count()) + self.assertIsNotNone(context.timeout) + + time.sleep(0.3) + + self.assertEqual(0, context.get_pending_count()) + self.assertEqual(1, len(published)) + context.close() + + def test_publish_timeout_reset_on_manual_publish(self): + self.set_up() + config = ContextConfig() + config.units = self.units + config.publish_delay = 1 + context = self.create_test_context(config, self.data_future_ready) + self.assertEqual(True, context.is_ready()) + + context.track("goal1", {"amount": 100}) + self.assertIsNotNone(context.timeout) + + context.publish() + + self.assertIsNone(context.timeout) + self.assertEqual(0, context.get_pending_count()) + context.close() + + def test_publish_timeout_with_multiple_events(self): + self.set_up() + config = ContextConfig() + config.units = self.units + config.publish_delay = 0.2 + context = self.create_test_context(config, self.data_future_ready) + self.assertEqual(True, context.is_ready()) + + published_events = [] + + def mock_publish(event): + published_events.append(event) + future = Future() + future.set_result(None) + return future + + self.client.publish = mock_publish + + context.track("goal1", {"amount": 100}) + context.track("goal2", {"amount": 200}) + context.track("goal3", {"amount": 300}) + context.get_treatment("exp_test_ab") + + self.assertEqual(4, context.get_pending_count()) + + time.sleep(0.4) + + self.assertEqual(0, context.get_pending_count()) + self.assertEqual(1, len(published_events)) + event = published_events[0] + self.assertEqual(3, len(event.goals)) + self.assertEqual(1, len(event.exposures)) + context.close() + + def test_publish_timeout_on_close(self): + self.set_up() + config = ContextConfig() + config.units = self.units + config.publish_delay = 10 + context = self.create_test_context(config, self.data_future_ready) + self.assertEqual(True, context.is_ready()) + + published_events = [] + + def mock_publish(event): + published_events.append(event) + future = Future() + future.set_result(None) + return future + + self.client.publish = mock_publish + + context.track("goal1", {"amount": 100}) + self.assertEqual(1, context.get_pending_count()) + + context.close() + + self.assertEqual(0, context.get_pending_count()) + self.assertEqual(1, len(published_events)) + self.assertTrue(context.is_closed()) + + def test_publish_timeout_edge_cases(self): + self.set_up() + config = ContextConfig() + config.units = self.units + config.publish_delay = 0 + context = self.create_test_context(config, self.data_future_ready) + self.assertEqual(True, context.is_ready()) + + context.track("goal1", {"amount": 100}) + self.assertIsNotNone(context.timeout) + + time.sleep(0.1) + self.assertEqual(0, context.get_pending_count()) + context.close() + + def test_set_attribute_single(self): + self.set_up() + config = ContextConfig() + config.units = self.units + context = self.create_test_context(config, self.data_future_ready) + self.assertEqual(True, context.is_ready()) + + context.set_attribute("user_age", 25) + context.set_attribute("user_country", "US") + + self.assertEqual(2, len(context.attributes)) + + names = [attr.name for attr in context.attributes] + self.assertIn("user_age", names) + self.assertIn("user_country", names) + context.close() + + def test_set_attributes_batch(self): + self.set_up() + config = ContextConfig() + config.units = self.units + context = self.create_test_context(config, self.data_future_ready) + self.assertEqual(True, context.is_ready()) + + attrs = { + "user_age": 25, + "user_country": "US", + "is_premium": True + } + context.set_attributes(attrs) + + self.assertEqual(3, len(context.attributes)) + + names = [attr.name for attr in context.attributes] + for key in attrs.keys(): + self.assertIn(key, names) + context.close() + + def test_get_attribute(self): + self.set_up() + config = ContextConfig() + config.units = self.units + context = self.create_test_context(config, self.data_future_ready) + self.assertEqual(True, context.is_ready()) + + context.set_attribute("user_age", 25) + + found = None + for attr in context.attributes: + if attr.name == "user_age": + found = attr + break + + self.assertIsNotNone(found) + self.assertEqual(25, found.value) + context.close() + + def test_attribute_persistence_across_publish(self): + self.set_up() + config = ContextConfig() + config.units = self.units + context = self.create_test_context(config, self.data_future_ready) + self.assertEqual(True, context.is_ready()) + + context.set_attribute("user_age", 25) + context.track("goal1", {"amount": 100}) + + context.publish() + + self.assertEqual(1, len(context.attributes)) + self.assertEqual("user_age", context.attributes[0].name) + self.assertEqual(25, context.attributes[0].value) + context.close() + + def test_set_unit_valid(self): + self.set_up() + config = ContextConfig() + context = self.create_test_context(config, self.data_future_ready) + self.assertEqual(True, context.is_ready()) + + context.set_unit("device_id", "device-123") + + self.assertIn("device_id", context.units) + self.assertEqual("device-123", context.units["device_id"]) + context.close() + + def test_set_unit_empty_throws(self): + self.set_up() + config = ContextConfig() + config.units = self.units + context = self.create_test_context(config, self.data_future_ready) + self.assertEqual(True, context.is_ready()) + + with self.assertRaises(ValueError) as ctx: + context.set_unit("device_id", "") + + self.assertEqual("Unit UID must not be blank.", str(ctx.exception)) + context.close() + + def test_set_unit_duplicate_throws(self): + self.set_up() + config = ContextConfig() + config.units = self.units + context = self.create_test_context(config, self.data_future_ready) + self.assertEqual(True, context.is_ready()) + + with self.assertRaises(ValueError) as ctx: + context.set_unit("user_id", "different-value") + + self.assertEqual("Unit already set.", str(ctx.exception)) + context.close() + + def test_set_units_batch(self): + self.set_up() + config = ContextConfig() + context = self.create_test_context(config, self.data_future_ready) + self.assertEqual(True, context.is_ready()) + + units = { + "device_id": "device-123", + "session_id": "session-456" + } + context.set_units(units) + + self.assertIn("device_id", context.units) + self.assertIn("session_id", context.units) + self.assertEqual("device-123", context.units["device_id"]) + self.assertEqual("session-456", context.units["session_id"]) + context.close() + + def test_recovery_from_failed_publish(self): + self.set_up() + config = ContextConfig() + config.units = self.units + context = self.create_test_context(config, self.data_future_ready) + self.assertEqual(True, context.is_ready()) + + publish_calls = [] + + def failing_publish(event): + publish_calls.append(event) + future = Future() + future.set_exception(RuntimeError("Publish failed")) + return future + + self.client.publish = failing_publish + + context.track("goal1", {"amount": 100}) + + try: + context.publish() + except RuntimeError: + pass + + self.assertFalse(context.is_closed()) + self.assertFalse(context.is_failed()) + context.close() + + def test_recovery_from_failed_refresh(self): + self.set_up() + config = ContextConfig() + config.units = self.units + context = self.create_test_context(config, self.data_future_ready) + self.assertEqual(True, context.is_ready()) + + def failing_get_context_data(): + future = Future() + future.set_exception(RuntimeError("Refresh failed")) + return future + + self.client.get_context_data = failing_get_context_data + + try: + context.refresh() + except RuntimeError: + pass + + self.assertTrue(context.is_ready()) + self.assertFalse(context.is_closed()) + context.close() + + def test_graceful_degradation_no_network(self): + self.set_up() + config = ContextConfig() + config.units = self.units + context = self.create_test_context(config, self.data_future_ready) + self.assertEqual(True, context.is_ready()) + + original_data = context.get_data() + + def network_failure(): + future = Future() + future.set_exception(ConnectionError("No network")) + return future + + self.client.get_context_data = network_failure + + try: + context.refresh() + except Exception: + pass + + current_data = context.get_data() + self.assertEqual(original_data, current_data) + + treatment = context.get_treatment("exp_test_ab") + self.assertEqual(self.expectedVariants["exp_test_ab"], treatment) + context.close() + + def test_error_callback_integration(self): + self.set_up() + error_events = [] + + class ErrorTrackingLogger(ContextEventLogger): + def handle_event(self, event_type: EventType, data: object): + if event_type == EventType.ERROR: + error_events.append(data) + + config = ContextConfig() + config.units = self.units + self.event_logger = ErrorTrackingLogger() + context = self.create_test_context(config, self.data_future_ready) + self.assertEqual(True, context.is_ready()) + + def failing_get_context_data(): + future = Future() + future.set_exception(RuntimeError("Test error")) + return future + + self.client.get_context_data = failing_get_context_data + + try: + context.refresh() + except RuntimeError: + pass + + self.assertEqual(1, len(error_events)) + self.assertIsInstance(error_events[0], RuntimeError) + context.close() diff --git a/test/test_provider.py b/test/test_provider.py new file mode 100644 index 0000000..a455d21 --- /dev/null +++ b/test/test_provider.py @@ -0,0 +1,91 @@ +import unittest +from concurrent.futures import Future, TimeoutError as FutureTimeoutError +from unittest.mock import MagicMock, Mock, patch + +from requests import Response +from requests.exceptions import Timeout, HTTPError, ConnectionError + +from sdk.client import Client +from sdk.client_config import ClientConfig +from sdk.default_context_data_provider import DefaultContextDataProvider +from sdk.default_http_client import DefaultHTTPClient +from sdk.default_http_client_config import DefaultHTTPClientConfig +from sdk.json.context_data import ContextData + + +class TestProviderGetContextData(unittest.TestCase): + + def setUp(self): + self.client_config = ClientConfig() + self.client_config.endpoint = "https://sandbox.test.io/v1" + self.client_config.api_key = "test-api-key" + self.client_config.application = "website" + self.client_config.environment = "dev" + + self.http_client = DefaultHTTPClient(DefaultHTTPClientConfig()) + self.client = Client(self.client_config, self.http_client) + self.provider = DefaultContextDataProvider(self.client) + + def test_provider_get_context_data_success(self): + response = Response() + response.status_code = 200 + response._content = bytes('{"experiments": []}', encoding="utf-8") + self.http_client.get = MagicMock(return_value=response) + + future = self.provider.get_context_data() + result = future.result(timeout=5) + + self.assertIsNotNone(result) + self.assertIsInstance(result, ContextData) + self.http_client.get.assert_called_once() + + def test_provider_get_context_data_timeout(self): + def raise_timeout(*args, **kwargs): + raise Timeout("Connection timed out") + + self.http_client.get = MagicMock(side_effect=raise_timeout) + + future = self.provider.get_context_data() + + with self.assertRaises(Timeout): + future.result(timeout=5) + + def test_provider_get_context_data_http_error(self): + response = Response() + response.status_code = 500 + response._content = bytes('{"error": "Internal Server Error"}', encoding="utf-8") + self.http_client.get = MagicMock(return_value=response) + + future = self.provider.get_context_data() + + with self.assertRaises(HTTPError): + future.result(timeout=5) + + def test_provider_get_context_data_connection_error(self): + def raise_connection_error(*args, **kwargs): + raise ConnectionError("Connection refused") + + self.http_client.get = MagicMock(side_effect=raise_connection_error) + + future = self.provider.get_context_data() + + with self.assertRaises(ConnectionError): + future.result(timeout=5) + + def test_provider_retry_configuration(self): + http_client_config = DefaultHTTPClientConfig() + http_client_config.max_retries = 3 + http_client_config.retry_interval = 0.1 + http_client = DefaultHTTPClient(http_client_config) + + https_adapter = http_client.http_client.get_adapter("https://") + self.assertIsNotNone(https_adapter) + self.assertEqual(3, https_adapter.max_retries.total) + + http_adapter = http_client.http_client.get_adapter("http://") + self.assertIsNotNone(http_adapter) + self.assertEqual(3, http_adapter.max_retries.total) + + +if __name__ == '__main__': + unittest.main() diff --git a/test/test_publisher.py b/test/test_publisher.py new file mode 100644 index 0000000..0601158 --- /dev/null +++ b/test/test_publisher.py @@ -0,0 +1,163 @@ +import unittest +from concurrent.futures import Future +from unittest.mock import MagicMock, Mock + +from requests import Response +from requests.exceptions import Timeout, HTTPError, ConnectionError + +from sdk.client import Client +from sdk.client_config import ClientConfig +from sdk.default_context_event_handler import DefaultContextEventHandler +from sdk.default_http_client import DefaultHTTPClient +from sdk.default_http_client_config import DefaultHTTPClientConfig +from sdk.json.attribute import Attribute +from sdk.json.exposure import Exposure +from sdk.json.goal_achievement import GoalAchievement +from sdk.json.publish_event import PublishEvent +from sdk.json.unit import Unit + + +class TestPublisher(unittest.TestCase): + + def setUp(self): + self.client_config = ClientConfig() + self.client_config.endpoint = "https://sandbox.test.io/v1" + self.client_config.api_key = "test-api-key" + self.client_config.application = "website" + self.client_config.environment = "dev" + + self.http_client = DefaultHTTPClient(DefaultHTTPClientConfig()) + self.client = Client(self.client_config, self.http_client) + self.publisher = DefaultContextEventHandler(self.client) + + def create_publish_event(self, with_exposures=True, with_goals=True): + event = PublishEvent() + event.hashed = True + event.publishedAt = 1620000000000 + + unit = Unit() + unit.type = "user_id" + unit.uid = "test-user-123" + event.units = [unit] + + if with_exposures: + exposure = Exposure() + exposure.id = 1 + exposure.name = "exp_test" + exposure.unit = "user_id" + exposure.variant = 1 + exposure.exposedAt = 1620000000000 + exposure.assigned = True + exposure.eligible = True + event.exposures = [exposure] + else: + event.exposures = [] + + if with_goals: + goal = GoalAchievement() + goal.name = "goal_test" + goal.achievedAt = 1620000000000 + goal.properties = {"amount": 100} + event.goals = [goal] + else: + event.goals = [] + + event.attributes = [] + + return event + + def test_publisher_publish_success(self): + response = Response() + response.status_code = 200 + response._content = bytes('{}', encoding="utf-8") + self.http_client.put = MagicMock(return_value=response) + + event = self.create_publish_event() + mock_context = Mock() + + future = self.publisher.publish(mock_context, event) + result = future.result(timeout=5) + + self.http_client.put.assert_called_once() + call_args = self.http_client.put.call_args + self.assertIn("https://sandbox.test.io/v1/context", call_args[0]) + + def test_publisher_publish_timeout(self): + def raise_timeout(*args, **kwargs): + raise Timeout("Connection timed out") + + self.http_client.put = MagicMock(side_effect=raise_timeout) + + event = self.create_publish_event() + mock_context = Mock() + + future = self.publisher.publish(mock_context, event) + + with self.assertRaises(Timeout): + future.result(timeout=5) + + def test_publisher_publish_http_error(self): + response = Response() + response.status_code = 500 + response._content = bytes('{"error": "Internal Server Error"}', encoding="utf-8") + self.http_client.put = MagicMock(return_value=response) + + event = self.create_publish_event() + mock_context = Mock() + + future = self.publisher.publish(mock_context, event) + + with self.assertRaises(HTTPError): + future.result(timeout=5) + + def test_publisher_batch_events(self): + response = Response() + response.status_code = 200 + response._content = bytes('{}', encoding="utf-8") + self.http_client.put = MagicMock(return_value=response) + + event = PublishEvent() + event.hashed = True + event.publishedAt = 1620000000000 + + unit = Unit() + unit.type = "user_id" + unit.uid = "test-user-123" + event.units = [unit] + + exposures = [] + for i in range(5): + exposure = Exposure() + exposure.id = i + exposure.name = f"exp_test_{i}" + exposure.unit = "user_id" + exposure.variant = 1 + exposure.exposedAt = 1620000000000 + i + exposure.assigned = True + exposure.eligible = True + exposures.append(exposure) + event.exposures = exposures + + goals = [] + for i in range(3): + goal = GoalAchievement() + goal.name = f"goal_test_{i}" + goal.achievedAt = 1620000000000 + i + goal.properties = {"amount": 100 * i} + goals.append(goal) + event.goals = goals + + event.attributes = [] + + mock_context = Mock() + + future = self.publisher.publish(mock_context, event) + result = future.result(timeout=5) + + self.http_client.put.assert_called_once() + call_args = self.http_client.put.call_args + self.assertIn("https://sandbox.test.io/v1/context", call_args[0]) + + +if __name__ == '__main__': + unittest.main() diff --git a/test/test_sdk.py b/test/test_sdk.py new file mode 100644 index 0000000..194a0e8 --- /dev/null +++ b/test/test_sdk.py @@ -0,0 +1,162 @@ +import unittest +from concurrent.futures import Future +from unittest.mock import MagicMock, Mock, patch + +from sdk.absmartly import ABSmartly +from sdk.absmartly_config import ABSmartlyConfig +from sdk.client import Client +from sdk.client_config import ClientConfig +from sdk.context import Context +from sdk.context_config import ContextConfig +from sdk.context_data_provider import ContextDataProvider +from sdk.context_event_handler import ContextEventHandler +from sdk.default_http_client import DefaultHTTPClient +from sdk.default_http_client_config import DefaultHTTPClientConfig +from sdk.json.context_data import ContextData +from sdk.json.experiment import Experiment + + +class TestSDKInitialization(unittest.TestCase): + + def test_sdk_create_with_valid_config(self): + client_config = ClientConfig() + client_config.endpoint = "https://sandbox.test.io/v1" + client_config.api_key = "test-api-key" + client_config.application = "website" + client_config.environment = "dev" + + http_client = DefaultHTTPClient(DefaultHTTPClientConfig()) + client = Client(client_config, http_client) + + config = ABSmartlyConfig() + config.client = client + + sdk = ABSmartly(config) + + self.assertIsNotNone(sdk) + self.assertIsNotNone(sdk.context_data_provider) + self.assertIsNotNone(sdk.context_event_handler) + self.assertIsNotNone(sdk.variable_parser) + self.assertIsNotNone(sdk.audience_deserializer) + + def test_sdk_create_missing_endpoint(self): + client_config = ClientConfig() + client_config.api_key = "test-api-key" + client_config.application = "website" + client_config.environment = "dev" + + http_client = DefaultHTTPClient(DefaultHTTPClientConfig()) + + self.assertIsNone(client_config.endpoint) + + with self.assertRaises(TypeError): + Client(client_config, http_client) + + def test_sdk_create_missing_api_key(self): + client_config = ClientConfig() + client_config.endpoint = "https://sandbox.test.io/v1" + client_config.application = "website" + client_config.environment = "dev" + + http_client = DefaultHTTPClient(DefaultHTTPClientConfig()) + client = Client(client_config, http_client) + + self.assertIsNone(client_config.api_key) + + def test_sdk_create_context(self): + mock_data_provider = Mock(spec=ContextDataProvider) + future_data = Future() + context_data = ContextData() + context_data.experiments = [] + future_data.set_result(context_data) + mock_data_provider.get_context_data.return_value = future_data + + mock_event_handler = Mock(spec=ContextEventHandler) + + config = ABSmartlyConfig() + config.context_data_provider = mock_data_provider + config.context_event_handler = mock_event_handler + + sdk = ABSmartly(config) + + context_config = ContextConfig() + context_config.units = {"user_id": "123456789"} + + context = sdk.create_context(context_config) + + self.assertIsNotNone(context) + self.assertIsInstance(context, Context) + mock_data_provider.get_context_data.assert_called_once() + + def test_sdk_create_context_with_data(self): + mock_data_provider = Mock(spec=ContextDataProvider) + mock_event_handler = Mock(spec=ContextEventHandler) + + config = ABSmartlyConfig() + config.context_data_provider = mock_data_provider + config.context_event_handler = mock_event_handler + + sdk = ABSmartly(config) + + context_config = ContextConfig() + context_config.units = {"user_id": "123456789"} + + pre_fetched_data = ContextData() + pre_fetched_data.experiments = [] + experiment = Experiment() + experiment.name = "test_experiment" + experiment.id = 1 + experiment.unitType = "user_id" + experiment.iteration = 1 + experiment.seedHi = 1 + experiment.seedLo = 1 + experiment.trafficSeedHi = 1 + experiment.trafficSeedLo = 1 + experiment.split = [50, 50] + experiment.trafficSplit = [100, 0] + experiment.fullOnVariant = 0 + experiment.variants = [] + pre_fetched_data.experiments.append(experiment) + + context = sdk.create_context_with(context_config, pre_fetched_data) + + self.assertIsNotNone(context) + self.assertIsInstance(context, Context) + self.assertTrue(context.is_ready()) + mock_data_provider.get_context_data.assert_not_called() + context.close() + + def test_sdk_close(self): + mock_data_provider = Mock(spec=ContextDataProvider) + future_data = Future() + context_data = ContextData() + context_data.experiments = [] + future_data.set_result(context_data) + mock_data_provider.get_context_data.return_value = future_data + + mock_event_handler = Mock(spec=ContextEventHandler) + publish_future = Future() + publish_future.set_result(None) + mock_event_handler.publish.return_value = publish_future + + config = ABSmartlyConfig() + config.context_data_provider = mock_data_provider + config.context_event_handler = mock_event_handler + + sdk = ABSmartly(config) + + context_config = ContextConfig() + context_config.units = {"user_id": "123456789"} + + context = sdk.create_context(context_config) + context.wait_until_ready() + + self.assertFalse(context.is_closed()) + + context.close() + + self.assertTrue(context.is_closed()) + + +if __name__ == '__main__': + unittest.main() From 657640650c44516d1c3db5f4311c67f7c3fb5b01 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Fri, 30 Jan 2026 14:13:10 +0000 Subject: [PATCH 04/11] docs: add platform examples and request configuration to Python3 SDK --- README.md | 618 +++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 468 insertions(+), 150 deletions(-) diff --git a/README.md b/README.md index a7d6c00..5762750 100644 --- a/README.md +++ b/README.md @@ -1,276 +1,594 @@ -# A/B Smartly SDK +# A/B Smartly Python SDK A/B Smartly - Python SDK ## Compatibility The A/B Smartly Python SDK is compatible with Python 3. -It provides both a blocking and an asynchronous interfaces. +It provides both a blocking and an asynchronous interface. ## Getting Started ### Install the SDK - ```bash - pip install absmartly==0.2.3 - ``` + +```bash +pip install absmartly==0.2.3 +``` ### Dependencies + ``` -setuptools~=60.2.0 -requests~=2.28.1 -urllib3~=1.26.12 +setuptools~=60.2.0 +requests~=2.28.1 +urllib3~=1.26.12 jsons~=1.6.3 ``` - - ## Import and Initialize the SDK Once the SDK is installed, it can be initialized in your project. + +### Recommended: Named Parameters (Simple) + +```python +from absmartly import ABsmartly, ContextConfig + +def main(): + # Create SDK with named parameters + sdk = ABsmartly.create( + endpoint="https://your-company.absmartly.io/v1", + api_key="YOUR-API-KEY", + application="website", + environment="production" + ) + + # Create a context + context_config = ContextConfig() + context_config.units = {"session_id": "5ebf06d8cb5d8137290c4abb64155584fbdb64d8"} + ctx = sdk.create_context(context_config) + ctx.wait_until_ready() +``` + +### With Optional Parameters + +```python +from absmartly import ABsmartly, ContextConfig + +sdk = ABsmartly.create( + endpoint="https://your-company.absmartly.io/v1", + api_key="YOUR-API-KEY", + application="website", + environment="production", + timeout=5, # Connection timeout in seconds (default: 3) + retries=3 # Max retries on failure (default: 5) +) +``` + +### Advanced: Manual Configuration + +For advanced use cases, you can manually configure all components: + ```python - def main(): - client_config = ClientConfig() - client_config.endpoint = "https://sandbox.test.io/v1" - client_config.api_key = "test" - client_config.application = "www" - client_config.environment = "prod" - - default_client_config = DefaultHTTPClientConfig() - default_client = DefaultHTTPClient(default_client_config) - sdk_config = ABSmartlyConfig() - sdk_config.client = Client(client_config, default_client) - sdk = ABSmartly(sdk_config) - - context_config = ContextConfig() - ctx = sdk.create_context(context_config) +from absmartly import ( + ABsmartly, + ABsmartlyConfig, + Client, + ClientConfig, + ContextConfig, + DefaultHTTPClient, + DefaultHTTPClientConfig, +) + +def main(): + # Configure the client + client_config = ClientConfig() + client_config.endpoint = "https://your-company.absmartly.io/v1" + client_config.api_key = "YOUR-API-KEY" + client_config.application = "website" + client_config.environment = "production" + + # Create HTTP client with optional configuration + default_client_config = DefaultHTTPClientConfig() + default_client_config.max_retries = 5 + default_client_config.connection_timeout = 3 + default_client = DefaultHTTPClient(default_client_config) + + # Configure and create the SDK + sdk_config = ABsmartlyConfig() + sdk_config.client = Client(client_config, default_client) + sdk = ABsmartly(sdk_config) + + # Create a context + context_config = ContextConfig() + context_config.units = {"session_id": "5ebf06d8cb5d8137290c4abb64155584fbdb64d8"} + ctx = sdk.create_context(context_config) + ctx.wait_until_ready() ``` -**SDK Options** +### SDK Options -| Config | Type | Required? | Default | Description | -| :---------- |:----------------------------------------------| :-------: |:---------------------------------------:|:------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| endpoint | `string` | ✅ | `undefined` | The URL to your API endpoint. Most commonly `"your-company.absmartly.io"` | -| apiKey | `string` | ✅ | `undefined` | Your API key which can be found on the Web Console. | -| environment | `"production"` or `"development"` | ✅ | `undefined` | The environment of the platform where the SDK is installed. Environments are created on the Web Console and should match the available environments in your infrastructure. | -| application | `string` | ✅ | `undefined` | The name of the application where the SDK is installed. Applications are created on the Web Console and should match the applications where your experiments will be running. | -| max_retries | `number` | ❌ | `5` | The number of retries before the SDK stops trying to connect. | -| connection_timeout | `number` | ❌ | `3` | An amount of time, in seconds, before the SDK will stop trying to connect. | -| context_event_logger | `(self, event_type: EventType, data: object)` | ❌ | See "Using a Custom Event Logger" below | A callback function which runs after SDK events. +| Config | Type | Required? | Default | Description | +| :--------------------- | :-------------------------------------------- | :-------: | :---------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| endpoint | `str` | ✅ | `None` | The URL to your API endpoint. Most commonly `"https://your-company.absmartly.io/v1"` | +| api_key | `str` | ✅ | `None` | Your API key which can be found on the Web Console. | +| environment | `str` | ✅ | `None` | The environment of the platform where the SDK is installed. Environments are created on the Web Console and should match the available environments in your infrastructure. | +| application | `str` | ✅ | `None` | The name of the application where the SDK is installed. Applications are created on the Web Console and should match the applications where your experiments will be running. | +| timeout | `int` | ❌ | `3` | Connection timeout in seconds before the SDK will stop trying to connect. | +| retries | `int` | ❌ | `5` | The number of retries before the SDK stops trying to connect. | +| event_logger | `ContextEventLogger` | ❌ | `None` | A callback handler which runs after SDK events. | +| context_data_provider | `ContextDataProvider` | ❌ | auto | Custom provider for context data (advanced usage, manual configuration only) | +| context_event_handler | `ContextEventHandler` | ❌ | auto | Custom handler for publishing events (advanced usage, manual configuration only) | + +### Using a Custom Event Logger -#### Using a custom Event Logger The A/B Smartly SDK can be instantiated with an event logger used for all contexts. In addition, an event logger can be specified when creating a particular context, in the `ContextConfig`. + ```python - class EventType(Enum): - ERROR = "error" - READY = "ready" - REFRESH = "refresh" - PUBLISH = "publish" - EXPOSURE = "exposure" - GOAL = "goal" - CLOSE = "close" - - - class ContextEventLogger: - - @abstractmethod - def handle_event(self, event_type: EventType, data: object): - raise NotImplementedError +from absmartly import ABsmartly +from sdk.context_event_logger import ContextEventLogger +from enum import Enum + +class EventType(Enum): + ERROR = "error" + READY = "ready" + REFRESH = "refresh" + PUBLISH = "publish" + EXPOSURE = "exposure" + GOAL = "goal" + CLOSE = "close" + + +class CustomEventLogger(ContextEventLogger): + def handle_event(self, context, event_type: EventType, data): + if event_type == EventType.ERROR: + print(f"Error: {data}") + elif event_type == EventType.READY: + print("Context is ready") + elif event_type == EventType.EXPOSURE: + print(f"Exposed to experiment: {data.name}") + elif event_type == EventType.GOAL: + print(f"Goal tracked: {data.name}") + elif event_type == EventType.REFRESH: + print("Context refreshed") + elif event_type == EventType.PUBLISH: + print("Events published") + elif event_type == EventType.CLOSE: + print("Context closed") + + +# Usage with named parameters +sdk = ABsmartly.create( + endpoint="https://your-company.absmartly.io/v1", + api_key="YOUR-API-KEY", + application="website", + environment="production", + event_logger=CustomEventLogger() +) + +# Or with advanced configuration +sdk_config.context_event_logger = CustomEventLogger() ``` + The data parameter depends on the type of event. -Currently, the SDK logs the following events: -| event | when | data | -|:---: |------------------------------------------------------------|---| -| `Error` | `Context` receives an error | `Throwable` object | -| `Ready` | `Context` turns ready | `ContextData` used to initialize the context | -| `Refresh` | `Context.refresh()` method succeeds | `ContextData` used to refresh the context | -| `Publish` | `Context.publish()` method succeeds | `PublishEvent` sent to the A/B Smartly event collector | -| `Exposure` | `Context.getTreatment()` method succeeds on first exposure | `Exposure` enqueued for publishing | -| `Goal` | `Context.track()` method succeeds | `GoalAchievement` enqueued for publishing | -| `Close` | `Context.close()` method succeeds the first time | `null` | +**Event Types** + +| Event | When | Data | +| :--------- | :--------------------------------------------------------- | :------------------------------------------ | +| `Error` | `Context` receives an error | Exception object | +| `Ready` | `Context` turns ready | `ContextData` used to initialize | +| `Refresh` | `Context.refresh()` method succeeds | `ContextData` used to refresh | +| `Publish` | `Context.publish()` method succeeds | `PublishEvent` sent to collector | +| `Exposure` | `Context.get_treatment()` method succeeds on first exposure| `Exposure` enqueued for publishing | +| `Goal` | `Context.track()` method succeeds | `GoalAchievement` enqueued for publishing | +| `Close` | `Context.close()` method succeeds the first time | `None` | ## Create a New Context Request -**Synchronously** +### Synchronously + ```python -# define a new context request - context_config = ContextConfig() - context_config.publish_delay = 10 - context_config.refresh_interval = 5 +# Define a new context request +context_config = ContextConfig() +context_config.units = {"session_id": "5ebf06d8cb5d8137290c4abb64155584fbdb64d8"} +context_config.publish_delay = 10 +context_config.refresh_interval = 5 - context_config = ContextConfig() - ctx = sdk.create_context(context_config) - ctx.wait_until_ready() +ctx = sdk.create_context(context_config) +ctx.wait_until_ready() + +if ctx: + print("Context ready") ``` -**Asynchronously** -```python -# define a new context request - context_config = ContextConfig() - context_config.publish_delay = 10 - context_config.refresh_interval = 5 +### Asynchronously - context_config = ContextConfig() - ctx = sdk.create_context(context_config) - ctx.wait_until_ready_async() +```python +# Define a new context request +context_config = ContextConfig() +context_config.units = {"session_id": "5ebf06d8cb5d8137290c4abb64155584fbdb64d8"} +context_config.publish_delay = 10 +context_config.refresh_interval = 5 + +ctx = sdk.create_context(context_config) +ctx.wait_until_ready_async() ``` -**With Prefetched Data** +### With Prefetched Data + +When doing full-stack experimentation with A/B Smartly, we recommend creating a context only once on the server-side. +Creating a context involves a round-trip to the A/B Smartly event collector. +We can avoid repeating the round-trip on the client-side by reusing the server-side context data. + ```python -# define a new context request - context_config = ContextConfig() - context_config.publish_delay = 10 - context_config.refresh_interval = 5 - context_config.units = {"session_id": "bf06d8cb5d8137290c4abb64155584fbdb64d8", - "user_id": "12345"} - - context_config = ContextConfig() - ctx = sdk.create_context(context_config) - ctx.wait_until_ready_async() +# Server-side: Create initial context +context_config = ContextConfig() +context_config.units = { + "session_id": "5ebf06d8cb5d8137290c4abb64155584fbdb64d8", + "user_id": "12345" +} + +ctx = sdk.create_context(context_config) +ctx.wait_until_ready() + +# Get the context data to pass to client +context_data = ctx.get_data() + +# Client-side: Create context with prefetched data +another_config = ContextConfig() +another_config.units = {"session_id": "another-user-session-id"} + +another_ctx = sdk.create_context_with(another_config, context_data) +# No need to wait - context is ready immediately ``` -**Refreshing the Context with Fresh Experiment Data** +### Refreshing the Context with Fresh Experiment Data + For long-running contexts, the context is usually created once when the application is first started. However, any experiments being tracked in your production code, but started after the context was created, will not be triggered. -To mitigate this, we can use the `set_refresh_interval()` method on the context config. +To mitigate this, we can use the `refresh_interval` parameter on the context config. ```python - default_client_config = DefaultHTTPClientConfig() - default_client_config.refresh_interval = 5 +context_config = ContextConfig() +context_config.units = {"session_id": "5ebf06d8cb5d8137290c4abb64155584fbdb64d8"} +context_config.refresh_interval = 5 # Refresh every 5 seconds + +ctx = sdk.create_context(context_config) ``` Alternatively, the `refresh()` method can be called manually. The `refresh()` method pulls updated experiment data from the A/B Smartly collector and will trigger recently started experiments when `get_treatment()` is called again. + ```python - context.refresh() +context.refresh() ``` -**Setting Extra Units** +### Setting Extra Units + You can add additional units to a context by calling the `set_unit()` or the `set_units()` method. This method may be used for example, when a user logs in to your application, and you want to use the new unit type to the context. + Please note that **you cannot override an already set unit type** as that would be a change of identity, and will throw an exception. In this case, you must create a new context instead. -The `SetUnit()` and `SetUnits()` methods can be called before the context is ready. + +The `set_unit()` and `set_units()` methods can be called before the context is ready. ```python - context.set_unit("db_user_id", "1000013") - - context.set_units({ - "db_user_id": "1000013" - }) +context.set_unit("db_user_id", "1000013") + +context.set_units({ + "db_user_id": "1000013" +}) ``` ## Basic Usage -#### Selecting a treatment +### Selecting a Treatment + ```python - res, _ = context.get_treatment("exp_test_experiment") - if res == 0: - # user is in control group (variant 0) - else: - # user is in treatment group +treatment = context.get_treatment("exp_test_experiment") + +if treatment == 0: + # User is in control group (variant 0) + pass +else: + # User is in treatment group + pass ``` ### Treatment Variables ```python - res = context.get_variable_value(key, 17) +# Get variable value with a default +button_color = context.get_variable_value("button.color", "red") ``` +### Peek at Treatment Variants -#### Peek at treatment variants Although generally not recommended, it is sometimes necessary to peek at a treatment or variable without triggering an exposure. -The A/B Smartly SDK provides a `peek_treament()` method for that. +The A/B Smartly SDK provides a `peek_treatment()` method for that. + +```python +treatment = context.peek_treatment("exp_test_experiment") + +if treatment == 0: + # User is in control group (variant 0) + pass +else: + # User is in treatment group + pass +``` + +#### Peeking at Variables ```python - res = context.peek_treament("exp_test_experiment") - if res == 0: - # user is in control group (variant 0) +variable = context.peek_variable("my_variable") +``` + +### Overriding Treatment Variants + +During development, for example, it is useful to force a treatment for an experiment. This can be achieved with the `set_override()` and/or `set_overrides()` methods. + +The `set_override()` and `set_overrides()` methods can be called before the context is ready. + +```python +# Force variant 1 of treatment +context.set_override("exp_test_experiment", 1) + +# Set multiple overrides at once +context.set_overrides({ + "exp_test_experiment": 1, + "exp_another_experiment": 0 +}) +``` + +## Platform-Specific Examples + +### Using with Flask + +```python +from flask import Flask, session, render_template +from absmartly import ABsmartly, ContextConfig + +app = Flask(__name__) + +# Initialize SDK once at app startup +sdk = ABsmartly.create( + endpoint="https://your-company.absmartly.io/v1", + api_key="YOUR-API-KEY", + application="website", + environment="production" +) + +@app.route('/') +def index(): + # Create context for this request + context_config = ContextConfig() + context_config.units = { + "session_id": session.get('session_id'), + "user_id": session.get('user_id') if 'user_id' in session else None + } + + ctx = sdk.create_context(context_config) + ctx.wait_until_ready() + + treatment = ctx.get_treatment("exp_test_experiment") + + # Use treatment to render different variants + if treatment == 0: + return render_template('control.html') else: - # user is in treatment group + return render_template('treatment.html') +``` + +### Using with Django + +```python +# settings.py +from absmartly import ABsmartly + +ABSMARTLY_SDK = ABsmartly.create( + endpoint="https://your-company.absmartly.io/v1", + api_key="YOUR-API-KEY", + application="website", + environment="production" +) + +# views.py +from django.conf import settings +from django.shortcuts import render +from absmartly import ContextConfig + +def my_view(request): + context_config = ContextConfig() + context_config.units = { + "session_id": request.session.session_key, + } + ctx = settings.ABSMARTLY_SDK.create_context(context_config) + ctx.wait_until_ready() + + treatment = ctx.get_treatment("exp_test_experiment") + + context = {'treatment': treatment} + return render(request, 'template.html', context) ``` -##### Peeking at variables + +### Using with FastAPI + ```python - variable = context.peek_variable("my_variable") +from fastapi import FastAPI, Request +from absmartly import ABsmartly, ContextConfig + +app = FastAPI() + +# Initialize SDK once at app startup +sdk = ABsmartly.create( + endpoint="https://your-company.absmartly.io/v1", + api_key="YOUR-API-KEY", + application="website", + environment="production" +) + +@app.get("/") +async def root(request: Request): + context_config = ContextConfig() + context_config.units = { + "session_id": request.session.get("session_id"), + } + + ctx = sdk.create_context(context_config) + await ctx.wait_until_ready_async() + + treatment = ctx.get_treatment("exp_test_experiment") + + return {"treatment": treatment} ``` -#### Overriding treatment variants -During development, for example, it is useful to force a treatment for an experiment. This can be achieved with the `override()` and/or `overrides()` methods. -The `set_override()` and `set_overrides()` methods can be called before the context is ready. +## Advanced Request Configuration + +### Request Timeout Override + +You can override the global timeout for individual context creation requests: + ```python - context.set_override("exp_test_experiment", 1) # force variant 1 of treatment - context.set_overrides({ - "exp_test_experiment": 1, - "exp_another_experiment": 0 - }) +from absmartly import ABsmartly, ContextConfig, DefaultHTTPClientConfig + +# Set timeout for this specific request +context_config = ContextConfig() +context_config.units = {"session_id": "abc123"} + +# Create HTTP client with custom timeout for this request +http_config = DefaultHTTPClientConfig() +http_config.connection_timeout = 1.5 # 1.5 seconds + +ctx = sdk.create_context(context_config) +# Note: Per-request timeout requires custom HTTP client configuration +``` + +### Request Cancellation + +For long-running requests that need to be cancelled (e.g., user navigating away): + +```python +import asyncio +from absmartly import ABsmartly, ContextConfig + +async def create_context_with_timeout(): + context_config = ContextConfig() + context_config.units = {"session_id": "abc123"} + + ctx = sdk.create_context(context_config) + + try: + # Wait for ready with timeout + await asyncio.wait_for(ctx.wait_until_ready_async(), timeout=1.5) + except asyncio.TimeoutError: + print("Context creation timed out") + # Context creation cancelled + + return ctx ``` ## Advanced ### Context Attributes + Attributes are used to pass meta-data about the user and/or the request. They can be used later in the Web Console to create segments or audiences. -The `set_attributes()` and `set_attributes()` methods can be called before the context is ready. + +The `set_attribute()` and `set_attributes()` methods can be called before the context is ready. + ```python - context.set_attributes("user_agent", req.get_header("User-Agent")) - - context.set_attributes({ - "customer_age": "new_customer" - }) +# Set a single attribute +context.set_attribute("user_agent", request.headers.get("User-Agent")) + +# Set multiple attributes at once +context.set_attributes({ + "customer_age": "new_customer", + "account_type": "premium" +}) ``` ### Custom Assignments -Sometimes it may be necessary to override the automatic selection of a -variant. For example, if you wish to have your variant chosen based on -data from an API call. This can be accomplished using the -`set_custom_assignment()` method. +Sometimes it may be necessary to override the automatic selection of a variant. For example, if you wish to have your variant chosen based on data from an API call. This can be accomplished using the `set_custom_assignment()` method. ```python - context.set_custom_assignment("exp_test_not_eligible", 3) +context.set_custom_assignment("exp_test_not_eligible", 3) ``` -If you are running multiple experiments and need to choose different -custom assignments for each one, you can do so using the -`set_custom_assignments()` method. +If you are running multiple experiments and need to choose different custom assignments for each one, you can do so using the `set_custom_assignments()` method. ```python - context.set_custom_assignments({"db_user_id2": 1}) +context.set_custom_assignments({ + "exp_test_experiment": 1, + "exp_another_experiment": 2 +}) +``` + +### Tracking Goals + +Goals are created in the A/B Smartly web console. + +```python +context.track("payment", { + "item_count": 1, + "total_amount": 1999.99 +}) ``` ### Publish + Sometimes it is necessary to ensure all events have been published to the A/B Smartly collector, before proceeding. You can explicitly call the `publish()` or `publish_async()` methods. + ```python - context.publish() +# Synchronous +context.publish() + +# Asynchronous +context.publish_async() ``` ### Finalize + The `close()` and `close_async()` methods will ensure all events have been published to the A/B Smartly collector, like `publish()`, and will also "seal" the context, throwing an error if any method that could generate an event is called. -```python - context.close() -``` -### Tracking Goals -Goals are created in the A/B Smartly web console. ```python - context.track("payment", { - "item_count": 1, - "total_amount": 1999.99 - }) +# Synchronous +context.close() + +# Asynchronous +context.close_async() ``` ## About A/B Smartly + **A/B Smartly** is the leading provider of state-of-the-art, on-premises, full-stack experimentation platforms for engineering and product teams that want to confidently deploy features as fast as they can develop them. A/B Smartly's real-time analytics helps engineering and product teams ensure that new features will improve the customer experience without breaking or degrading performance and/or business metrics. ### Have a look at our growing list of clients and SDKs: -- [Java SDK](https://www.github.com/absmartly/java-sdk) - [JavaScript SDK](https://www.github.com/absmartly/javascript-sdk) +- [Java SDK](https://www.github.com/absmartly/java-sdk) - [PHP SDK](https://www.github.com/absmartly/php-sdk) - [Swift SDK](https://www.github.com/absmartly/swift-sdk) - [Vue2 SDK](https://www.github.com/absmartly/vue2-sdk) +- [Vue3 SDK](https://www.github.com/absmartly/vue3-sdk) +- [React SDK](https://www.github.com/absmartly/react-sdk) +- [Python3 SDK](https://www.github.com/absmartly/python3-sdk) (this package) - [Go SDK](https://www.github.com/absmartly/go-sdk) - [Ruby SDK](https://www.github.com/absmartly/ruby-sdk) +- [.NET SDK](https://www.github.com/absmartly/dotnet-sdk) +- [Dart SDK](https://www.github.com/absmartly/dart-sdk) +- [Flutter SDK](https://www.github.com/absmartly/flutter-sdk) + +## Documentation + +- [Full Documentation](https://docs.absmartly.com/) +- [Web Console](https://absmartly.com/) + +## License + +MIT License - see LICENSE for details. From a6e9413985385e73237b1bc0a929ba536512cd98 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Fri, 30 Jan 2026 14:17:40 +0000 Subject: [PATCH 05/11] fix: correct API signatures and imports in Python3 SDK docs --- README.md | 25 +++++++++---------------- 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 5762750..1770b0f 100644 --- a/README.md +++ b/README.md @@ -125,22 +125,11 @@ The A/B Smartly SDK can be instantiated with an event logger used for all contex In addition, an event logger can be specified when creating a particular context, in the `ContextConfig`. ```python -from absmartly import ABsmartly -from sdk.context_event_logger import ContextEventLogger -from enum import Enum - -class EventType(Enum): - ERROR = "error" - READY = "ready" - REFRESH = "refresh" - PUBLISH = "publish" - EXPOSURE = "exposure" - GOAL = "goal" - CLOSE = "close" +from absmartly import ABsmartly, ContextEventLogger, EventType class CustomEventLogger(ContextEventLogger): - def handle_event(self, context, event_type: EventType, data): + def handle_event(self, event_type: EventType, data): if event_type == EventType.ERROR: print(f"Error: {data}") elif event_type == EventType.READY: @@ -183,6 +172,7 @@ The data parameter depends on the type of event. | `Exposure` | `Context.get_treatment()` method succeeds on first exposure| `Exposure` enqueued for publishing | | `Goal` | `Context.track()` method succeeds | `GoalAchievement` enqueued for publishing | | `Close` | `Context.close()` method succeeds the first time | `None` | +| `Finalize` | `Context.close()` method succeeds | `None` | ## Create a New Context Request @@ -323,7 +313,7 @@ else: #### Peeking at Variables ```python -variable = context.peek_variable("my_variable") +variable = context.peek_variable_value("my_variable", None) ``` ### Overriding Treatment Variants @@ -367,7 +357,7 @@ def index(): context_config = ContextConfig() context_config.units = { "session_id": session.get('session_id'), - "user_id": session.get('user_id') if 'user_id' in session else None + "user_id": session.get('user_id') } ctx = sdk.create_context(context_config) @@ -420,6 +410,7 @@ def my_view(request): ```python from fastapi import FastAPI, Request from absmartly import ABsmartly, ContextConfig +import uuid app = FastAPI() @@ -433,9 +424,11 @@ sdk = ABsmartly.create( @app.get("/") async def root(request: Request): + # Note: In production, use session middleware to manage session_id + # Example: Starlette SessionMiddleware with a cookie-based session context_config = ContextConfig() context_config.units = { - "session_id": request.session.get("session_id"), + "session_id": str(uuid.uuid4()), } ctx = sdk.create_context(context_config) From 61a193e419a2f6ee18d4c71ec3fd0c80efb0398c Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Fri, 6 Feb 2026 19:53:50 +0000 Subject: [PATCH 06/11] =?UTF-8?q?test:=20add=20canonical=20test=20parity?= =?UTF-8?q?=20(143=20=E2=86=92=20346=20tests)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 203 tests aligned with canonical test specs: MD5 (14), Murmur3 (36), variant assignment (66), audience matcher (3), and context canonical tests (87). Fix test_variable_value bug using wrong fixture. --- test/test_audience_matcher.py | 37 ++ test/test_context.py | 4 +- test/test_context_canonical.py | 1002 ++++++++++++++++++++++++++++++++ test/test_md5.py | 70 +++ test/test_murmur32.py | 169 ++++-- test/test_variant_assigner.py | 361 ++++++++---- 6 files changed, 1473 insertions(+), 170 deletions(-) create mode 100644 test/test_audience_matcher.py create mode 100644 test/test_context_canonical.py create mode 100644 test/test_md5.py diff --git a/test/test_audience_matcher.py b/test/test_audience_matcher.py new file mode 100644 index 0000000..9ff9a6e --- /dev/null +++ b/test/test_audience_matcher.py @@ -0,0 +1,37 @@ +import unittest + +from sdk.audience_matcher import AudienceMatcher +from sdk.default_audience_deserializer import DefaultAudienceDeserializer + + +class AudienceMatcherTest(unittest.TestCase): + + def setUp(self): + self.matcher = AudienceMatcher(DefaultAudienceDeserializer()) + + def test_returns_none_on_empty_audience(self): + result = self.matcher.evaluate("{}", {}) + self.assertIsNone(result) + + def test_returns_none_if_filter_not_object_or_array(self): + result = self.matcher.evaluate('{"filter": "string_value"}', {}) + self.assertIsNone(result) + + result = self.matcher.evaluate('{"filter": 123}', {}) + self.assertIsNone(result) + + result = self.matcher.evaluate('{"filter": true}', {}) + self.assertIsNone(result) + + result = self.matcher.evaluate('{"filter": null}', {}) + self.assertIsNone(result) + + def test_returns_boolean_for_valid_filter(self): + audience = '{"filter":[{"gte":[{"var":"age"},{"value":20}]}]}' + result = self.matcher.evaluate(audience, {"age": 25}) + self.assertIsNotNone(result) + self.assertTrue(result.result) + + result = self.matcher.evaluate(audience, {"age": 15}) + self.assertIsNotNone(result) + self.assertFalse(result.result) diff --git a/test/test_context.py b/test/test_context.py index 6389785..8717353 100644 --- a/test/test_context.py +++ b/test/test_context.py @@ -376,7 +376,7 @@ def set_result(): context.set_attribute("attr1", "value1") except RuntimeError as e: self.assertIsNotNone(e) - self.assertEqual("ABSmartly Context is closing", str(e)) + self.assertEqual("ABsmartly Context is closing", str(e)) time.sleep(0.3) context.close() @@ -416,7 +416,7 @@ def set_result(): context.set_attribute("attr1", "value1") except RuntimeError as e: self.assertIsNotNone(e) - self.assertEqual("ABSmartly Context is closed", str(e)) + self.assertEqual("ABsmartly Context is closed", str(e)) time.sleep(0.3) context.close() diff --git a/test/test_context_canonical.py b/test/test_context_canonical.py new file mode 100644 index 0000000..d0248f0 --- /dev/null +++ b/test/test_context_canonical.py @@ -0,0 +1,1002 @@ +import copy +import json +import os +import threading +import time +import typing +import unittest +from concurrent.futures import Future + +from sdk.context_config import ContextConfig +from sdk.context import Context +from sdk.client import Client +from sdk.client_config import ClientConfig +from sdk.default_context_event_handler import DefaultContextEventHandler +from sdk.default_context_data_provider import DefaultContextDataProvider +from sdk.audience_matcher import AudienceMatcher +from sdk.default_http_client import DefaultHTTPClient +from sdk.default_http_client_config import DefaultHTTPClientConfig +from sdk.default_variable_parser import DefaultVariableParser +from sdk.context_event_logger import ContextEventLogger, EventType +from sdk.context_event_handler import ContextEventHandler +from sdk.context_data_provider import ContextDataProvider +from sdk.default_audience_deserializer import DefaultAudienceDeserializer +from sdk.default_context_data_deserializer import DefaultContextDataDeserializer +from sdk.json.attribute import Attribute +from sdk.json.context_data import ContextData +from sdk.json.exposure import Exposure +from sdk.json.goal_achievement import GoalAchievement +from sdk.json.publish_event import PublishEvent +from sdk.json.unit import Unit +from sdk.time.clock import Clock +from sdk.time.fixed_clock import FixedClock + + +class EventLoggerCapture(ContextEventLogger): + def __init__(self): + self.events = [] + + def handle_event(self, event_type: EventType, data: object): + self.events.append((event_type, data)) + + @property + def last_type(self): + return self.events[-1][0] if self.events else None + + @property + def last_data(self): + return self.events[-1][1] if self.events else None + + def clear(self): + self.events.clear() + + def count_type(self, event_type): + return sum(1 for e in self.events if e[0] == event_type) + + +class ClientContextMock(Client): + def __init__(self, config, http_client): + super().__init__(config, http_client) + self._publish_future = None + self._refresh_data = None + self._publish_calls = [] + + def get_context_data(self): + future = Future() + if self._refresh_data is not None: + future.set_result(self._refresh_data) + else: + context_data = ContextData() + context_data.experiments = [] + future.set_result(context_data) + return future + + def publish(self, event: PublishEvent): + self._publish_calls.append(event) + if self._publish_future is not None: + return self._publish_future + future = Future() + future.set_result(None) + return future + + +class ContextCanonicalTestBase(unittest.TestCase): + + expectedVariants = { + "exp_test_ab": 1, + "exp_test_abc": 2, + "exp_test_not_eligible": 0, + "exp_test_fullon": 2, + "exp_test_new": 1, + } + + expectedVariables = { + "banner.border": 1.0, + "banner.size": "large", + "button.color": "red", + "submit.color": "blue", + "submit.shape": "rect", + "show-modal": True, + } + + units = { + "session_id": "e791e240fcd3df7d238cfc285f475e8152fcc0ec", + "user_id": "123456789", + "email": "bleh@absmartly.com" + } + + deser = DefaultContextDataDeserializer() + audeser = DefaultAudienceDeserializer() + + def set_up(self): + with open(os.path.join(os.path.dirname(__file__), 'res/context.json'), 'r') as file: + content = file.read() + with open(os.path.join(os.path.dirname(__file__), 'res/context-strict.json'), 'r') as file: + content_strict = file.read() + with open(os.path.join(os.path.dirname(__file__), 'res/refreshed.json'), 'r') as file: + refreshed = file.read() + + self.data = self.deser.deserialize(bytes(content, encoding="utf-8"), 0, len(content)) + self.audience_strict_data = self.deser.deserialize( + bytes(content_strict, encoding="utf-8"), 0, len(content_strict)) + self.refresh_data = self.deser.deserialize( + bytes(refreshed, encoding="utf-8"), 0, len(refreshed)) + + self.data_future_ready = Future() + self.data_future_ready.set_result(self.data) + self.data_future = Future() + self.data_future_failed = Future() + self.data_future_failed.set_exception(RuntimeError("FAILED")) + self.data_future_strict = Future() + self.data_future_strict.set_result(self.audience_strict_data) + self.data_future_refresh = Future() + self.data_future_refresh.set_result(self.refresh_data) + + self.clock = FixedClock(1_620_000_000_000) + client_config = ClientConfig() + client_config.endpoint = "https://sandbox.test.io/v1" + client_config.api_key = "gfsgsgsf" + client_config.application = "www" + client_config.environment = "test" + default_client_config = DefaultHTTPClientConfig() + default_client = DefaultHTTPClient(default_client_config) + self.client = ClientContextMock(client_config, default_client) + self.data_provider = DefaultContextDataProvider(self.client) + self.event_handler = DefaultContextEventHandler(self.client) + self.event_logger = EventLoggerCapture() + self.variable_parser = DefaultVariableParser() + self.audience_matcher = AudienceMatcher(self.audeser) + + def create_context(self, config, data_future): + return Context( + self.clock, config, data_future, + self.data_provider, self.event_handler, + self.event_logger, self.variable_parser, + self.audience_matcher) + + def create_ready_context(self, **kwargs): + config = ContextConfig() + config.units = kwargs.get('units', self.units) + if 'overrides' in kwargs: + config.overrides = kwargs['overrides'] + if 'cassignments' in kwargs: + config.cassigmnents = kwargs['cassignments'] + data_future = kwargs.get('data_future', self.data_future_ready) + return self.create_context(config, data_future) + + +class ContextEventLoggerTests(ContextCanonicalTestBase): + + def test_event_logger_on_ready_success(self): + self.set_up() + context = self.create_ready_context() + self.assertTrue(context.is_ready()) + self.assertEqual(self.event_logger.last_type, EventType.READY) + self.assertIsInstance(self.event_logger.last_data, ContextData) + context.close() + + def test_event_logger_on_ready_failure(self): + self.set_up() + config = ContextConfig() + config.units = self.units + context = self.create_context(config, self.data_future_failed) + self.assertTrue(context.is_ready()) + self.assertTrue(context.is_failed()) + self.assertEqual(self.event_logger.last_type, EventType.ERROR) + self.assertIsInstance(self.event_logger.last_data, RuntimeError) + context.close() + + def test_event_logger_on_exposure(self): + self.set_up() + context = self.create_ready_context() + context.get_treatment("exp_test_ab") + self.assertEqual(self.event_logger.last_type, EventType.EXPOSURE) + self.assertIsInstance(self.event_logger.last_data, Exposure) + context.close() + + def test_event_logger_on_goal(self): + self.set_up() + context = self.create_ready_context() + context.track("goal1", {"amount": 100}) + self.assertEqual(self.event_logger.last_type, EventType.GOAL) + self.assertIsInstance(self.event_logger.last_data, GoalAchievement) + context.close() + + def test_event_logger_on_publish_success(self): + self.set_up() + context = self.create_ready_context() + context.track("goal1", {"amount": 100}) + context.publish() + self.assertEqual(self.event_logger.last_type, EventType.PUBLISH) + context.close() + + def test_event_logger_on_publish_error(self): + self.set_up() + context = self.create_ready_context() + context.track("goal1", {"amount": 100}) + fail_future = Future() + fail_future.set_exception(RuntimeError("publish error")) + self.client._publish_future = fail_future + try: + context.publish() + except RuntimeError: + pass + self.assertEqual(self.event_logger.last_type, EventType.ERROR) + context.close() + + def test_event_logger_on_refresh_success(self): + self.set_up() + context = self.create_ready_context() + self.client._refresh_data = self.data + context.refresh() + has_refresh = any(e[0] == EventType.REFRESH for e in self.event_logger.events) + self.assertTrue(has_refresh) + context.close() + + def test_event_logger_on_refresh_error(self): + self.set_up() + context = self.create_ready_context() + + def failing_get(): + future = Future() + future.set_exception(RuntimeError("refresh error")) + return future + self.client.get_context_data = failing_get + + try: + context.refresh() + except RuntimeError: + pass + self.assertEqual(self.event_logger.last_type, EventType.ERROR) + context.close() + + def test_event_logger_on_close(self): + self.set_up() + context = self.create_ready_context() + context.close() + self.assertEqual(self.event_logger.last_type, EventType.CLOSE) + + +class ContextTreatmentTests(ContextCanonicalTestBase): + + def test_treatment_queues_exposure(self): + self.set_up() + context = self.create_ready_context() + for experiment in self.data.experiments: + result = context.get_treatment(experiment.name) + self.assertEqual(self.expectedVariants[experiment.name], result) + self.assertGreater(context.get_pending_count(), 0) + context.close() + + def test_treatment_queues_exposure_only_once(self): + self.set_up() + context = self.create_ready_context() + context.get_treatment("exp_test_ab") + count_after_first = context.get_pending_count() + context.get_treatment("exp_test_ab") + self.assertEqual(count_after_first, context.get_pending_count()) + context.close() + + def test_treatment_queues_exposure_after_peek(self): + self.set_up() + context = self.create_ready_context() + context.peek_treatment("exp_test_ab") + self.assertEqual(0, context.get_pending_count()) + context.get_treatment("exp_test_ab") + self.assertEqual(1, context.get_pending_count()) + context.close() + + def test_treatment_returns_base_variant_for_unknown_experiment(self): + self.set_up() + context = self.create_ready_context() + result = context.get_treatment("unknown_experiment") + self.assertEqual(0, result) + self.assertEqual(1, context.get_pending_count()) + context.close() + + def test_treatment_does_not_requeue_unknown_experiment(self): + self.set_up() + context = self.create_ready_context() + context.get_treatment("unknown_experiment") + self.assertEqual(1, context.get_pending_count()) + context.get_treatment("unknown_experiment") + self.assertEqual(1, context.get_pending_count()) + context.close() + + def test_treatment_queues_exposure_with_audience_match_true(self): + self.set_up() + context = self.create_ready_context(data_future=self.data_future_strict) + context.set_attribute("age", 25) + result = context.get_treatment("exp_test_ab") + self.assertEqual(1, result) + self.assertEqual(1, context.get_pending_count()) + exposure = context.exposures[0] + self.assertFalse(exposure.audienceMismatch) + context.close() + + def test_treatment_queues_exposure_with_audience_mismatch_false_nonstrict(self): + self.set_up() + context = self.create_ready_context(data_future=self.data_future_strict) + result = context.get_treatment("exp_test_ab") + self.assertEqual(0, result) + self.assertEqual(1, context.get_pending_count()) + exposure = context.exposures[0] + self.assertTrue(exposure.audienceMismatch) + context.close() + + def test_treatment_queues_exposure_with_override_variant(self): + self.set_up() + context = self.create_ready_context(overrides={"exp_test_ab": 2}) + result = context.get_treatment("exp_test_ab") + self.assertEqual(2, result) + self.assertEqual(1, context.get_pending_count()) + exposure = context.exposures[0] + self.assertTrue(exposure.overridden) + context.close() + + def test_treatment_queues_exposure_with_custom_assignment(self): + self.set_up() + context = self.create_ready_context(cassignments={"exp_test_ab": 2}) + result = context.get_treatment("exp_test_ab") + self.assertEqual(2, result) + self.assertEqual(1, context.get_pending_count()) + exposure = context.exposures[0] + self.assertTrue(exposure.custom) + context.close() + + +class ContextPeekTests(ContextCanonicalTestBase): + + def test_peek_does_not_queue_exposures(self): + self.set_up() + context = self.create_ready_context() + for experiment in self.data.experiments: + context.peek_treatment(experiment.name) + self.assertEqual(0, context.get_pending_count()) + context.close() + + def test_peek_returns_override_variant(self): + self.set_up() + context = self.create_ready_context(overrides={"exp_test_ab": 2}) + result = context.peek_treatment("exp_test_ab") + self.assertEqual(2, result) + self.assertEqual(0, context.get_pending_count()) + context.close() + + def test_peek_returns_assigned_variant_on_audience_mismatch_nonstrict(self): + self.set_up() + context = self.create_ready_context() + result = context.peek_treatment("exp_test_ab") + self.assertEqual(self.expectedVariants["exp_test_ab"], result) + self.assertEqual(0, context.get_pending_count()) + context.close() + + def test_peek_returns_control_variant_on_audience_mismatch_strict(self): + self.set_up() + context = self.create_ready_context(data_future=self.data_future_strict) + result = context.peek_treatment("exp_test_ab") + self.assertEqual(0, result) + self.assertEqual(0, context.get_pending_count()) + context.close() + + +class ContextVariableValueTests(ContextCanonicalTestBase): + + def test_variable_value_returns_default_when_unassigned(self): + self.set_up() + context = self.create_ready_context() + result = context.get_variable_value("nonexistent_var", "default") + self.assertEqual("default", result) + context.close() + + def test_variable_value_returns_override_values(self): + self.set_up() + context = self.create_ready_context(overrides={"exp_test_ab": 1}) + result = context.get_variable_value("banner.size", "default") + self.assertEqual("large", result) + context.close() + + def test_variable_value_queues_exposure(self): + self.set_up() + context = self.create_ready_context() + context.get_variable_value("banner.size", "default") + self.assertEqual(1, context.get_pending_count()) + context.close() + + def test_variable_value_queues_exposure_only_once(self): + self.set_up() + context = self.create_ready_context() + context.get_variable_value("banner.size", "default") + count = context.get_pending_count() + context.get_variable_value("banner.size", "default") + self.assertEqual(count, context.get_pending_count()) + context.close() + + def test_variable_value_queues_exposure_after_peek(self): + self.set_up() + context = self.create_ready_context() + context.peek_variable_value("banner.size", "default") + self.assertEqual(0, context.get_pending_count()) + context.get_variable_value("banner.size", "default") + self.assertEqual(1, context.get_pending_count()) + context.close() + + def test_variable_value_strict_returns_default_on_mismatch(self): + self.set_up() + context = self.create_ready_context(data_future=self.data_future_strict) + result = context.get_variable_value("banner.size", "small") + self.assertEqual("small", result) + context.close() + + def test_variable_value_returns_correct_types(self): + self.set_up() + context = self.create_ready_context(data_future=self.data_future_refresh) + self.assertEqual(1.0, context.get_variable_value("banner.border", 0)) + self.assertEqual("large", context.get_variable_value("banner.size", "")) + self.assertEqual("red", context.get_variable_value("button.color", "")) + self.assertEqual(True, context.get_variable_value("show-modal", False)) + context.close() + + +class ContextPeekVariableValueTests(ContextCanonicalTestBase): + + def test_peek_variable_value_returns_default_when_unassigned(self): + self.set_up() + context = self.create_ready_context() + result = context.peek_variable_value("nonexistent_var", "default") + self.assertEqual("default", result) + context.close() + + def test_peek_variable_value_returns_override_values(self): + self.set_up() + context = self.create_ready_context(overrides={"exp_test_ab": 1}) + result = context.peek_variable_value("banner.size", "default") + self.assertEqual("large", result) + context.close() + + def test_peek_variable_value_does_not_queue_exposure(self): + self.set_up() + context = self.create_ready_context() + context.peek_variable_value("banner.size", "default") + self.assertEqual(0, context.get_pending_count()) + context.close() + + def test_peek_variable_value_strict_returns_default_on_mismatch(self): + self.set_up() + context = self.create_ready_context(data_future=self.data_future_strict) + result = context.peek_variable_value("banner.size", "small") + self.assertEqual("small", result) + self.assertEqual(0, context.get_pending_count()) + context.close() + + def test_peek_variable_value_nonstrict_returns_assigned_on_mismatch(self): + self.set_up() + context = self.create_ready_context() + result = context.peek_variable_value("banner.size", "small") + self.assertEqual("large", result) + context.close() + + +class ContextVariableKeysTests(ContextCanonicalTestBase): + + def test_variable_keys_returns_all_active_keys(self): + self.set_up() + context = self.create_ready_context(data_future=self.data_future_refresh) + keys = context.get_variable_keys() + expected = { + "banner.border": "exp_test_ab", + "banner.size": "exp_test_ab", + "button.color": "exp_test_abc", + "card.width": "exp_test_not_eligible", + "submit.color": "exp_test_fullon", + "submit.shape": "exp_test_fullon", + "show-modal": "exp_test_new", + } + self.assertEqual(expected, keys) + context.close() + + +class ContextTrackTests(ContextCanonicalTestBase): + + def test_track_queues_goals(self): + self.set_up() + context = self.create_ready_context() + context.track("goal1", {"amount": 125, "hours": 245}) + self.assertEqual(1, context.get_pending_count()) + context.close() + + def test_track_calls_event_logger(self): + self.set_up() + context = self.create_ready_context() + context.track("goal1", {"amount": 100}) + goal_events = [e for e in self.event_logger.events if e[0] == EventType.GOAL] + self.assertEqual(1, len(goal_events)) + self.assertIsInstance(goal_events[0][1], GoalAchievement) + context.close() + + def test_track_accepts_number_properties(self): + self.set_up() + context = self.create_ready_context() + context.track("goal1", {"amount": 125, "hours": 245}) + self.assertEqual(1, context.get_pending_count()) + context.close() + + def test_track_accepts_none_properties(self): + self.set_up() + context = self.create_ready_context() + context.track("goal1", None) + self.assertEqual(1, context.get_pending_count()) + context.close() + + def test_track_callable_before_ready(self): + self.set_up() + config = ContextConfig() + config.units = self.units + context = self.create_context(config, self.data_future) + self.assertFalse(context.is_ready()) + context.track("goal1", {"amount": 100}) + self.assertEqual(1, context.get_pending_count()) + context.close() + + def test_track_throws_after_close(self): + self.set_up() + context = self.create_ready_context() + context.close() + with self.assertRaises(RuntimeError): + context.track("goal1", {"amount": 100}) + + def test_track_with_timestamp(self): + self.set_up() + context = self.create_ready_context() + context.track("goal1", {"amount": 100}, achieved_at=1713218400000) + achievement = context.achievements[0] + self.assertEqual(1713218400000, achievement.achievedAt) + context.close() + + +class ContextPublishTests(ContextCanonicalTestBase): + + def test_publish_does_not_call_client_when_empty(self): + self.set_up() + context = self.create_ready_context() + context.publish() + self.assertEqual(0, len(self.client._publish_calls)) + context.close() + + def test_publish_calls_client(self): + self.set_up() + context = self.create_ready_context() + context.track("goal1", {"amount": 100}) + context.publish() + self.assertEqual(1, len(self.client._publish_calls)) + context.close() + + def test_publish_includes_exposure_data(self): + self.set_up() + context = self.create_ready_context() + context.get_treatment("exp_test_ab") + context.publish() + self.assertEqual(1, len(self.client._publish_calls)) + event = self.client._publish_calls[0] + self.assertIsNotNone(event.exposures) + self.assertEqual(1, len(event.exposures)) + self.assertEqual("exp_test_ab", event.exposures[0].name) + context.close() + + def test_publish_includes_goal_data(self): + self.set_up() + context = self.create_ready_context() + context.track("goal1", {"amount": 100}) + context.publish() + event = self.client._publish_calls[0] + self.assertIsNotNone(event.goals) + self.assertEqual(1, len(event.goals)) + self.assertEqual("goal1", event.goals[0].name) + context.close() + + def test_publish_includes_attribute_data(self): + self.set_up() + context = self.create_ready_context() + context.set_attribute("attr1", "value1") + context.track("goal1", {"amount": 100}) + context.publish() + event = self.client._publish_calls[0] + self.assertIsNotNone(event.attributes) + self.assertEqual(1, len(event.attributes)) + self.assertEqual("attr1", event.attributes[0].name) + context.close() + + def test_publish_clears_queue_on_success(self): + self.set_up() + context = self.create_ready_context() + context.track("goal1", {"amount": 100}) + self.assertEqual(1, context.get_pending_count()) + context.publish() + self.assertEqual(0, context.get_pending_count()) + context.close() + + def test_publish_propagates_client_error(self): + self.set_up() + context = self.create_ready_context() + context.track("goal1", {"amount": 100}) + fail_future = Future() + fail_future.set_exception(RuntimeError("publish failed")) + self.client._publish_future = fail_future + with self.assertRaises(RuntimeError): + context.publish() + context.close() + + def test_publish_throws_after_close(self): + self.set_up() + context = self.create_ready_context() + context.close() + with self.assertRaises(RuntimeError): + context.publish() + + +class ContextFinalizeTests(ContextCanonicalTestBase): + + def test_finalize_does_not_publish_when_empty(self): + self.set_up() + context = self.create_ready_context() + context.close() + self.assertEqual(0, len(self.client._publish_calls)) + self.assertTrue(context.is_closed()) + + def test_finalize_publishes_pending_events(self): + self.set_up() + context = self.create_ready_context() + context.track("goal1", {"amount": 100}) + context.close() + self.assertEqual(1, len(self.client._publish_calls)) + self.assertTrue(context.is_closed()) + + def test_finalize_includes_exposure_data(self): + self.set_up() + context = self.create_ready_context() + context.get_treatment("exp_test_ab") + context.close() + self.assertEqual(1, len(self.client._publish_calls)) + event = self.client._publish_calls[0] + self.assertIsNotNone(event.exposures) + self.assertEqual(1, len(event.exposures)) + + def test_finalize_includes_goal_data(self): + self.set_up() + context = self.create_ready_context() + context.track("goal1", {"amount": 100}) + context.close() + event = self.client._publish_calls[0] + self.assertIsNotNone(event.goals) + self.assertEqual(1, len(event.goals)) + + def test_finalize_includes_attribute_data(self): + self.set_up() + context = self.create_ready_context() + context.set_attribute("attr1", "value1") + context.track("goal1", {"amount": 100}) + context.close() + event = self.client._publish_calls[0] + self.assertIsNotNone(event.attributes) + self.assertEqual(1, len(event.attributes)) + + def test_finalize_clears_queue(self): + self.set_up() + context = self.create_ready_context() + context.track("goal1", {"amount": 100}) + context.close() + self.assertEqual(0, context.get_pending_count()) + + def test_finalize_stops_refresh_timer(self): + self.set_up() + context = self.create_ready_context(data_future=self.data_future_refresh) + self.assertIsNotNone(context.refresh_timer) + context.close() + self.assertIsNone(context.refresh_timer) + + +class ContextRefreshTests(ContextCanonicalTestBase): + + def test_refresh_loads_new_data(self): + self.set_up() + context = self.create_ready_context() + self.client._refresh_data = self.refresh_data + context.refresh() + experiments = context.get_experiments() + self.assertIn("exp_test_new", experiments) + context.close() + + def test_refresh_throws_after_close(self): + self.set_up() + context = self.create_ready_context() + context.close() + with self.assertRaises(RuntimeError): + context.refresh() + + def test_refresh_keeps_overrides(self): + self.set_up() + context = self.create_ready_context(overrides={"exp_test_ab": 2}) + self.client._refresh_data = self.refresh_data + context.refresh() + result = context.get_treatment("exp_test_ab") + self.assertEqual(2, result) + context.close() + + def test_refresh_keeps_custom_assignments(self): + self.set_up() + context = self.create_ready_context(cassignments={"exp_test_ab": 2}) + self.client._refresh_data = self.refresh_data + context.refresh() + result = context.get_treatment("exp_test_ab") + self.assertEqual(2, result) + context.close() + + def test_refresh_not_requeue_when_not_changed(self): + self.set_up() + context = self.create_ready_context() + context.get_treatment("exp_test_ab") + initial_count = context.get_pending_count() + self.client._refresh_data = self.data + context.refresh() + context.get_treatment("exp_test_ab") + self.assertEqual(initial_count, context.get_pending_count()) + context.close() + + def test_refresh_not_requeue_with_override(self): + self.set_up() + context = self.create_ready_context(overrides={"exp_test_ab": 2}) + context.get_treatment("exp_test_ab") + initial_count = context.get_pending_count() + self.client._refresh_data = self.data + context.refresh() + context.get_treatment("exp_test_ab") + self.assertEqual(initial_count, context.get_pending_count()) + context.close() + + def test_refresh_picks_up_experiment_started(self): + self.set_up() + context = self.create_ready_context() + result = context.get_treatment("exp_test_new") + self.assertEqual(0, result) + self.client._refresh_data = self.refresh_data + context.refresh() + result = context.get_treatment("exp_test_new") + self.assertEqual(1, result) + context.close() + + def test_refresh_picks_up_experiment_stopped(self): + self.set_up() + context = self.create_ready_context(data_future=self.data_future_refresh) + result = context.get_treatment("exp_test_new") + self.assertEqual(1, result) + self.client._refresh_data = self.data + context.refresh() + result = context.get_treatment("exp_test_new") + self.assertEqual(0, result) + context.close() + + +class ContextUnitTests(ContextCanonicalTestBase): + + def test_set_unit_before_ready(self): + self.set_up() + config = ContextConfig() + context = self.create_context(config, self.data_future) + self.assertFalse(context.is_ready()) + context.set_unit("session_id", "some-session") + self.assertIn("session_id", context.units) + context.close() + + def test_set_unit_throws_after_close(self): + self.set_up() + context = self.create_ready_context() + context.close() + with self.assertRaises(RuntimeError): + context.set_unit("device_id", "device-123") + + +class ContextAttributeTests(ContextCanonicalTestBase): + + def test_set_attribute_before_ready(self): + self.set_up() + config = ContextConfig() + config.units = self.units + context = self.create_context(config, self.data_future) + self.assertFalse(context.is_ready()) + context.set_attribute("user_age", 25) + self.assertEqual(1, len(context.attributes)) + context.close() + + def test_get_attribute_returns_last_set_value(self): + self.set_up() + context = self.create_ready_context() + context.set_attribute("user_age", 25) + context.set_attribute("user_age", 30) + age_attrs = [a for a in context.attributes if a.name == "user_age"] + self.assertEqual(2, len(age_attrs)) + self.assertEqual(30, age_attrs[-1].value) + context.close() + + +class ContextOverrideTests(ContextCanonicalTestBase): + + def test_override_callable_before_ready(self): + self.set_up() + config = ContextConfig() + config.units = self.units + context = self.create_context(config, self.data_future) + self.assertFalse(context.is_ready()) + context.set_override("exp_test_ab", 2) + self.assertEqual(2, context.get_override("exp_test_ab")) + context.close() + + +class ContextCustomAssignmentTests(ContextCanonicalTestBase): + + def test_custom_assignment_overrides_natural(self): + self.set_up() + context = self.create_ready_context() + context.set_custom_assignment("exp_test_ab", 2) + result = context.get_treatment("exp_test_ab") + self.assertEqual(2, result) + exposure = context.exposures[0] + self.assertTrue(exposure.custom) + context.close() + + def test_custom_assignment_does_not_override_fullon(self): + self.set_up() + context = self.create_ready_context() + context.set_custom_assignment("exp_test_fullon", 3) + result = context.get_treatment("exp_test_fullon") + self.assertEqual(2, result) + context.close() + + def test_custom_assignment_does_not_override_not_eligible(self): + self.set_up() + context = self.create_ready_context() + context.set_custom_assignment("exp_test_not_eligible", 3) + result = context.get_treatment("exp_test_not_eligible") + self.assertEqual(0, result) + context.close() + + def test_custom_assignment_callable_before_ready(self): + self.set_up() + config = ContextConfig() + config.units = self.units + context = self.create_context(config, self.data_future) + self.assertFalse(context.is_ready()) + context.set_custom_assignment("exp_test_ab", 2) + self.assertEqual(2, context.get_custom_assignment("exp_test_ab")) + context.close() + + def test_custom_assignment_throws_after_close(self): + self.set_up() + context = self.create_ready_context() + context.close() + with self.assertRaises(RuntimeError): + context.set_custom_assignment("exp_test_ab", 2) + + +class ContextCustomFieldTests(ContextCanonicalTestBase): + + def test_custom_field_keys(self): + self.set_up() + context = self.create_ready_context() + keys = context.get_custom_field_keys() + self.assertEqual(["country", "languages", "overrides"], keys) + context.close() + + def test_custom_field_value_string(self): + self.set_up() + context = self.create_ready_context() + result = context.get_custom_field_value("exp_test_ab", "country") + self.assertEqual("US,PT,ES,DE,FR", result) + context.close() + + def test_custom_field_value_json(self): + self.set_up() + context = self.create_ready_context() + result = context.get_custom_field_value("exp_test_ab", "overrides") + self.assertEqual({'123': 1, '456': 0}, result) + context.close() + + def test_custom_field_value_type(self): + self.set_up() + context = self.create_ready_context() + result = context.get_custom_field_type("exp_test_ab", "overrides") + self.assertEqual("json", result) + context.close() + + def test_custom_field_value_returns_none_nonexistent(self): + self.set_up() + context = self.create_ready_context() + self.assertIsNone(context.get_custom_field_value("not_found", "not_found")) + self.assertIsNone(context.get_custom_field_value("exp_test_ab", "not_found")) + context.close() + + def test_custom_field_value_returns_none_no_custom_fields(self): + self.set_up() + context = self.create_ready_context() + self.assertIsNone(context.get_custom_field_value("exp_test_no_custom_fields", "country")) + context.close() + + +class ContextNotReadyTests(ContextCanonicalTestBase): + + def test_throws_when_not_ready(self): + self.set_up() + config = ContextConfig() + config.units = self.units + context = self.create_context(config, self.data_future) + self.assertFalse(context.is_ready()) + with self.assertRaises(RuntimeError): + context.get_treatment("exp_test_ab") + context.close() + + def test_peek_throws_when_not_ready(self): + self.set_up() + config = ContextConfig() + config.units = self.units + context = self.create_context(config, self.data_future) + self.assertFalse(context.is_ready()) + with self.assertRaises(RuntimeError): + context.peek_treatment("exp_test_ab") + context.close() + + def test_variable_value_throws_when_not_ready(self): + self.set_up() + config = ContextConfig() + config.units = self.units + context = self.create_context(config, self.data_future) + self.assertFalse(context.is_ready()) + with self.assertRaises(RuntimeError): + context.get_variable_value("banner.size", "default") + context.close() + + +class ContextPublishResetTests(ContextCanonicalTestBase): + + def test_publish_resets_queues_keeps_attributes_overrides_assignments(self): + self.set_up() + context = self.create_ready_context() + context.set_override("exp_override", 2) + context.set_custom_assignment("exp_test_ab", 2) + context.set_attribute("attr1", "value1") + context.get_treatment("exp_test_ab") + context.track("goal1", {"amount": 100}) + self.assertGreater(context.get_pending_count(), 0) + context.publish() + self.assertEqual(0, context.get_pending_count()) + self.assertEqual(2, context.get_override("exp_override")) + self.assertEqual(2, context.get_custom_assignment("exp_test_ab")) + self.assertTrue(len(context.attributes) > 0) + context.close() + + +class ContextPublishDelayTests(ContextCanonicalTestBase): + + def test_publish_delay_triggers_after_exposure(self): + self.set_up() + config = ContextConfig() + config.units = self.units + config.publish_delay = 0.1 + context = self.create_context(config, self.data_future_ready) + context.get_treatment("exp_test_ab") + self.assertEqual(1, context.get_pending_count()) + self.assertIsNotNone(context.timeout) + time.sleep(0.3) + self.assertEqual(0, context.get_pending_count()) + context.close() + + def test_publish_delay_triggers_after_goal(self): + self.set_up() + config = ContextConfig() + config.units = self.units + config.publish_delay = 0.1 + context = self.create_context(config, self.data_future_ready) + context.track("goal1", {"amount": 100}) + self.assertEqual(1, context.get_pending_count()) + self.assertIsNotNone(context.timeout) + time.sleep(0.3) + self.assertEqual(0, context.get_pending_count()) + context.close() diff --git a/test/test_md5.py b/test/test_md5.py new file mode 100644 index 0000000..eba8602 --- /dev/null +++ b/test/test_md5.py @@ -0,0 +1,70 @@ +import base64 +import hashlib +import unittest + + +class MD5Test(unittest.TestCase): + + @staticmethod + def md5_base64url(input_str): + dig = hashlib.md5(input_str.encode('utf-8')).digest() + return base64.urlsafe_b64encode(dig).rstrip(b'=').decode('ascii') + + def test_empty_string(self): + self.assertEqual("1B2M2Y8AsgTpgAmY7PhCfg", self.md5_base64url("")) + + def test_single_space(self): + self.assertEqual("chXunH2dwinSkhpA6JnsXw", self.md5_base64url(" ")) + + def test_single_char_t(self): + self.assertEqual("41jvpIn1gGLxDdcxa2Vkng", self.md5_base64url("t")) + + def test_two_chars_te(self): + self.assertEqual("Vp73JkK-D63XEdakaNaO4Q", self.md5_base64url("te")) + + def test_three_chars_tes(self): + self.assertEqual("KLZi2IO212_Zbk3cXpungA", self.md5_base64url("tes")) + + def test_four_chars_test(self): + self.assertEqual("CY9rzUYh03PK3k6DJie09g", self.md5_base64url("test")) + + def test_five_chars_testy(self): + self.assertEqual("K5I_V6RgP8c6sYKz-TVn8g", self.md5_base64url("testy")) + + def test_six_chars_testy1(self): + self.assertEqual("8fT8xGipOhPkZ2DncKU-1A", self.md5_base64url("testy1")) + + def test_seven_chars_testy12(self): + self.assertEqual("YqRAtOz000gIu61ErEH18A", self.md5_base64url("testy12")) + + def test_eight_chars_testy123(self): + self.assertEqual("pfV2H07L6WvdqlY0zHuYIw", self.md5_base64url("testy123")) + + def test_special_characters(self): + self.assertEqual( + "4PIrO7lKtTxOcj2eMYlG7A", + self.md5_base64url("special characters a\u00e7b\u2193c")) + + def test_quick_brown_fox(self): + self.assertEqual( + "nhB9nTcrtoJr2B01QqQZ1g", + self.md5_base64url("The quick brown fox jumps over the lazy dog")) + + def test_quick_brown_fox_eats_pie(self): + self.assertEqual( + "iM-8ECRrLUQzixl436y96A", + self.md5_base64url( + "The quick brown fox jumps over the lazy dog and eats a pie")) + + def test_lorem_ipsum(self): + self.assertEqual( + "24m7XOq4f5wPzCqzbBicLA", + self.md5_base64url( + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, " + "sed do eiusmod tempor incididunt ut labore et dolore magna " + "aliqua. Ut enim ad minim veniam, quis nostrud exercitation " + "ullamco laboris nisi ut aliquip ex ea commodo consequat. " + "Duis aute irure dolor in reprehenderit in voluptate velit " + "esse cillum dolore eu fugiat nulla pariatur. Excepteur sint " + "occaecat cupidatat non proident, sunt in culpa qui officia " + "deserunt mollit anim id est laborum.")) diff --git a/test/test_murmur32.py b/test/test_murmur32.py index 35f1602..e4de026 100644 --- a/test/test_murmur32.py +++ b/test/test_murmur32.py @@ -3,52 +3,123 @@ class Murmur32Test(unittest.TestCase): - def test_digest(self): - test_cases = [["", 0x00000000, 0x00000000], - [" ", 0x00000000, 0x7ef49b98], - ["t", 0x00000000, 0xca87df4d], - ["te", 0x00000000, 0xedb8ee1b], - ["tes", 0x00000000, 0x0bb90e5a], - ["test", 0x00000000, 0xba6bd213], - ["testy", 0x00000000, 0x44af8342], - ["testy1", 0x00000000, 0x8a1a243a], - ["testy12", 0x00000000, 0x845461b9], - ["testy123", 0x00000000, 0x47628ac4], - ["special characters açb↓c", - 0x00000000, 0xbe83b140], - ["The quick brown fox jumps over the lazy dog", - 0x00000000, 0x2e4ff723], - ["", 0xdeadbeef, 0x0de5c6a9], - [" ", 0xdeadbeef, 0x25acce43], - ["t", 0xdeadbeef, 0x3b15dcf8], - ["te", 0xdeadbeef, 0xac981332], - ["tes", 0xdeadbeef, 0xc1c78dda], - ["test", 0xdeadbeef, 0xaa22d41a], - ["testy", 0xdeadbeef, 0x84f5f623], - ["testy1", 0xdeadbeef, 0x09ed28e9], - ["testy12", 0xdeadbeef, 0x22467835], - ["testy123", 0xdeadbeef, 0xd633060d], - ["special characters açb↓c", - 0xdeadbeef, 0xf7fdd8a2], - ["The quick brown fox jumps over the lazy dog", - 0xdeadbeef, 0x3a7b3f4d], - ["", 0x00000001, 0x514e28b7], - [" ", 0x00000001, 0x4f0f7132], - ["t", 0x00000001, 0x5db1831e], - ["te", 0x00000001, 0xd248bb2e], - ["tes", 0x00000001, 0xd432eb74], - ["test", 0x00000001, 0x99c02ae2], - ["testy", 0x00000001, 0xc5b2dc1e], - ["testy1", 0x00000001, 0x33925ceb], - ["testy12", 0x00000001, 0xd92c9f23], - ["testy123", 0x00000001, 0x3bc1712d], - ["special characters açb↓c", - 0x00000001, 0x293327b5], - ["The quick brown fox jumps over the lazy dog", - 0x00000001, 0x78e69e27]] - - for case in test_cases: - key = bytearray(case[0].encode('utf-8')) - actual = murmur.digest(key, int(case[1])) - expected = murmur.to_signed32(case[2]) - self.assertEqual(expected, actual) + + def _assert_hash(self, input_str, seed, expected_hex): + key = bytearray(input_str.encode('utf-8')) + actual = murmur.digest(key, seed) + expected = murmur.to_signed32(expected_hex) + self.assertEqual(expected, actual) + + def test_seed0_empty(self): + self._assert_hash("", 0x00000000, 0x00000000) + + def test_seed0_space(self): + self._assert_hash(" ", 0x00000000, 0x7ef49b98) + + def test_seed0_t(self): + self._assert_hash("t", 0x00000000, 0xca87df4d) + + def test_seed0_te(self): + self._assert_hash("te", 0x00000000, 0xedb8ee1b) + + def test_seed0_tes(self): + self._assert_hash("tes", 0x00000000, 0x0bb90e5a) + + def test_seed0_test(self): + self._assert_hash("test", 0x00000000, 0xba6bd213) + + def test_seed0_testy(self): + self._assert_hash("testy", 0x00000000, 0x44af8342) + + def test_seed0_testy1(self): + self._assert_hash("testy1", 0x00000000, 0x8a1a243a) + + def test_seed0_testy12(self): + self._assert_hash("testy12", 0x00000000, 0x845461b9) + + def test_seed0_testy123(self): + self._assert_hash("testy123", 0x00000000, 0x47628ac4) + + def test_seed0_special_characters(self): + self._assert_hash("special characters a\u00e7b\u2193c", 0x00000000, 0xbe83b140) + + def test_seed0_quick_brown_fox(self): + self._assert_hash( + "The quick brown fox jumps over the lazy dog", + 0x00000000, 0x2e4ff723) + + def test_deadbeef_empty(self): + self._assert_hash("", 0xdeadbeef, 0x0de5c6a9) + + def test_deadbeef_space(self): + self._assert_hash(" ", 0xdeadbeef, 0x25acce43) + + def test_deadbeef_t(self): + self._assert_hash("t", 0xdeadbeef, 0x3b15dcf8) + + def test_deadbeef_te(self): + self._assert_hash("te", 0xdeadbeef, 0xac981332) + + def test_deadbeef_tes(self): + self._assert_hash("tes", 0xdeadbeef, 0xc1c78dda) + + def test_deadbeef_test(self): + self._assert_hash("test", 0xdeadbeef, 0xaa22d41a) + + def test_deadbeef_testy(self): + self._assert_hash("testy", 0xdeadbeef, 0x84f5f623) + + def test_deadbeef_testy1(self): + self._assert_hash("testy1", 0xdeadbeef, 0x09ed28e9) + + def test_deadbeef_testy12(self): + self._assert_hash("testy12", 0xdeadbeef, 0x22467835) + + def test_deadbeef_testy123(self): + self._assert_hash("testy123", 0xdeadbeef, 0xd633060d) + + def test_deadbeef_special_characters(self): + self._assert_hash("special characters a\u00e7b\u2193c", 0xdeadbeef, 0xf7fdd8a2) + + def test_deadbeef_quick_brown_fox(self): + self._assert_hash( + "The quick brown fox jumps over the lazy dog", + 0xdeadbeef, 0x3a7b3f4d) + + def test_seed1_empty(self): + self._assert_hash("", 0x00000001, 0x514e28b7) + + def test_seed1_space(self): + self._assert_hash(" ", 0x00000001, 0x4f0f7132) + + def test_seed1_t(self): + self._assert_hash("t", 0x00000001, 0x5db1831e) + + def test_seed1_te(self): + self._assert_hash("te", 0x00000001, 0xd248bb2e) + + def test_seed1_tes(self): + self._assert_hash("tes", 0x00000001, 0xd432eb74) + + def test_seed1_test(self): + self._assert_hash("test", 0x00000001, 0x99c02ae2) + + def test_seed1_testy(self): + self._assert_hash("testy", 0x00000001, 0xc5b2dc1e) + + def test_seed1_testy1(self): + self._assert_hash("testy1", 0x00000001, 0x33925ceb) + + def test_seed1_testy12(self): + self._assert_hash("testy12", 0x00000001, 0xd92c9f23) + + def test_seed1_testy123(self): + self._assert_hash("testy123", 0x00000001, 0x3bc1712d) + + def test_seed1_special_characters(self): + self._assert_hash("special characters a\u00e7b\u2193c", 0x00000001, 0x293327b5) + + def test_seed1_quick_brown_fox(self): + self._assert_hash( + "The quick brown fox jumps over the lazy dog", + 0x00000001, 0x78e69e27) diff --git a/test/test_variant_assigner.py b/test/test_variant_assigner.py index e3270ea..943ed2c 100644 --- a/test/test_variant_assigner.py +++ b/test/test_variant_assigner.py @@ -5,122 +5,245 @@ import sdk.internal.variant_assigner as assigner -class VariantAssignerTest(unittest.TestCase): - def test_choose_variant(self): - self.assertEqual(1, assigner.VariantAssigner.choose_variant( - [0.0, 1.0], 0.0)) - self.assertEqual(1, assigner.VariantAssigner.choose_variant( - [0.0, 1.0], 0.5)) - self.assertEqual(1, assigner.VariantAssigner.choose_variant( - [0.0, 1.0], 1.0)) - - self.assertEqual(0, assigner.VariantAssigner.choose_variant( - [1.0, 0.0], 0.0)) - self.assertEqual(0, assigner.VariantAssigner.choose_variant( - [1.0, 0.0], 0.5)) - self.assertEqual(1, assigner.VariantAssigner.choose_variant( - [1.0, 0.0], 1.0)) - - self.assertEqual(0, assigner.VariantAssigner.choose_variant( - [0.5, 0.5], 0.0)) - self.assertEqual(0, assigner.VariantAssigner.choose_variant( - [0.5, 0.5], 0.25)) - self.assertEqual(0, assigner.VariantAssigner.choose_variant( - [0.5, 0.5], 0.49999999)) - self.assertEqual(1, assigner.VariantAssigner.choose_variant( - [0.5, 0.5], 0.5)) - self.assertEqual(1, assigner.VariantAssigner.choose_variant( - [0.5, 0.5], 0.50000001)) - self.assertEqual(1, assigner.VariantAssigner.choose_variant( - [0.5, 0.5], 0.75)) - self.assertEqual(1, assigner.VariantAssigner.choose_variant( - [0.5, 0.5], 1.0)) - - self.assertEqual(0, assigner.VariantAssigner.choose_variant( - [0.333, 0.333, 0.334], 0.0)) - self.assertEqual(0, assigner.VariantAssigner.choose_variant( - [0.333, 0.333, 0.334], 0.25)) - self.assertEqual(0, assigner.VariantAssigner.choose_variant( - [0.333, 0.333, 0.334], 0.33299999)) - self.assertEqual(1, assigner.VariantAssigner.choose_variant( - [0.333, 0.333, 0.334], 0.333)) - self.assertEqual(1, assigner.VariantAssigner.choose_variant( - [0.333, 0.333, 0.334], 0.33300001)) - self.assertEqual(1, assigner.VariantAssigner.choose_variant( - [0.333, 0.333, 0.334], 0.5)) - self.assertEqual(1, assigner.VariantAssigner.choose_variant( - [0.333, 0.333, 0.334], 0.66599999)) - self.assertEqual(2, assigner.VariantAssigner.choose_variant( - [0.333, 0.333, 0.334], 0.666)) - self.assertEqual(2, assigner.VariantAssigner.choose_variant( - [0.333, 0.333, 0.334], 0.66600001)) - self.assertEqual(2, assigner.VariantAssigner.choose_variant( - [0.333, 0.333, 0.334], 0.75)) - self.assertEqual(2, assigner.VariantAssigner.choose_variant( - [0.333, 0.333, 0.334], 1.0)) - self.assertEqual(1, assigner.VariantAssigner.choose_variant( - [0.0, 1.0], 0.0)) - self.assertEqual(1, assigner.VariantAssigner.choose_variant( - [0.0, 1.0], 1.0)) - - def test_assignments_match(self): - splits = [[0.5, 0.5], - [0.5, 0.5], - [0.5, 0.5], - [0.5, 0.5], - [0.5, 0.5], - [0.5, 0.5], - [0.5, 0.5], - [0.33, 0.33, 0.34], - [0.33, 0.33, 0.34], - [0.33, 0.33, 0.34], - [0.33, 0.33, 0.34], - [0.33, 0.33, 0.34], - [0.33, 0.33, 0.34], - [0.33, 0.33, 0.34]] - - seeds = [[0x00000000, 0x00000000], - [0x00000000, 0x00000001], - [0x8015406f, 0x7ef49b98], - [0x3b2e7d90, 0xca87df4d], - [0x52c1f657, 0xd248bb2e], - [0x865a84d0, 0xaa22d41a], - [0x27d1dc86, 0x845461b9], - [0x00000000, 0x00000000], - [0x00000000, 0x00000001], - [0x8015406f, 0x7ef49b98], - [0x3b2e7d90, 0xca87df4d], - [0x52c1f657, 0xd248bb2e], - [0x865a84d0, 0xaa22d41a], - [0x27d1dc86, 0x845461b9]] - - unituid = "bleh@absmartly.com" - dig = hashlib.md5(unituid.encode('utf-8')).digest() - unithash = base64.urlsafe_b64encode(dig).rstrip(b'=') - var_assigner = assigner.VariantAssigner(bytearray(unithash)) - expected_variants = [0, 1, 0, 0, 0, 0, 1, 0, 2, 0, 0, 0, 1, 1] - - self.assert_variants(seeds, splits, expected_variants, var_assigner) - - unituid = str(123456789) - dig = hashlib.md5(unituid.encode('utf-8')).digest() - unithash = base64.urlsafe_b64encode(dig).rstrip(b'=') - var_assigner = assigner.VariantAssigner(bytearray(unithash)) - expected_variants = [1, 0, 1, 1, 1, 0, 0, 2, 1, 2, 2, 2, 0, 0] - - self.assert_variants(seeds, splits, expected_variants, var_assigner) - - unituid = "e791e240fcd3df7d238cfc285f475e8152fcc0ec" - dig = hashlib.md5(unituid.encode('utf-8')).digest() - unithash = base64.urlsafe_b64encode(dig).rstrip(b'=') - var_assigner = assigner.VariantAssigner(bytearray(unithash)) - expected_variants = [1, 0, 1, 1, 0, 0, 0, 2, 0, 2, 1, 0, 0, 1] - - self.assert_variants(seeds, splits, expected_variants, var_assigner) - - def assert_variants(self, seeds, splits, expected_variants, var_assigner): - for index, seed in enumerate(seeds): - frags = seed - split = splits[index] - variant = var_assigner.assign(split, frags[0], frags[1]) - self.assertEqual(expected_variants[index], variant) +def hash_unit(unit): + dig = hashlib.md5(str(unit).encode('utf-8')).digest() + return base64.urlsafe_b64encode(dig).rstrip(b'=') + + +class VariantAssignerChooseVariantTest(unittest.TestCase): + + def test_choose_variant_0_100_at_0(self): + self.assertEqual(1, assigner.VariantAssigner.choose_variant([0.0, 1.0], 0.0)) + + def test_choose_variant_0_100_at_50(self): + self.assertEqual(1, assigner.VariantAssigner.choose_variant([0.0, 1.0], 0.5)) + + def test_choose_variant_0_100_at_100(self): + self.assertEqual(1, assigner.VariantAssigner.choose_variant([0.0, 1.0], 1.0)) + + def test_choose_variant_100_0_at_0(self): + self.assertEqual(0, assigner.VariantAssigner.choose_variant([1.0, 0.0], 0.0)) + + def test_choose_variant_100_0_at_50(self): + self.assertEqual(0, assigner.VariantAssigner.choose_variant([1.0, 0.0], 0.5)) + + def test_choose_variant_100_0_at_100(self): + self.assertEqual(1, assigner.VariantAssigner.choose_variant([1.0, 0.0], 1.0)) + + def test_choose_variant_50_50_at_0(self): + self.assertEqual(0, assigner.VariantAssigner.choose_variant([0.5, 0.5], 0.0)) + + def test_choose_variant_50_50_at_25(self): + self.assertEqual(0, assigner.VariantAssigner.choose_variant([0.5, 0.5], 0.25)) + + def test_choose_variant_50_50_at_49(self): + self.assertEqual(0, assigner.VariantAssigner.choose_variant([0.5, 0.5], 0.49999999)) + + def test_choose_variant_50_50_at_50(self): + self.assertEqual(1, assigner.VariantAssigner.choose_variant([0.5, 0.5], 0.5)) + + def test_choose_variant_50_50_at_51(self): + self.assertEqual(1, assigner.VariantAssigner.choose_variant([0.5, 0.5], 0.50000001)) + + def test_choose_variant_50_50_at_75(self): + self.assertEqual(1, assigner.VariantAssigner.choose_variant([0.5, 0.5], 0.75)) + + def test_choose_variant_50_50_at_100(self): + self.assertEqual(1, assigner.VariantAssigner.choose_variant([0.5, 0.5], 1.0)) + + def test_choose_variant_333_at_0(self): + self.assertEqual(0, assigner.VariantAssigner.choose_variant([0.333, 0.333, 0.334], 0.0)) + + def test_choose_variant_333_at_25(self): + self.assertEqual(0, assigner.VariantAssigner.choose_variant([0.333, 0.333, 0.334], 0.25)) + + def test_choose_variant_333_at_332(self): + self.assertEqual(0, assigner.VariantAssigner.choose_variant([0.333, 0.333, 0.334], 0.33299999)) + + def test_choose_variant_333_at_333(self): + self.assertEqual(1, assigner.VariantAssigner.choose_variant([0.333, 0.333, 0.334], 0.333)) + + def test_choose_variant_333_at_334(self): + self.assertEqual(1, assigner.VariantAssigner.choose_variant([0.333, 0.333, 0.334], 0.33300001)) + + def test_choose_variant_333_at_50(self): + self.assertEqual(1, assigner.VariantAssigner.choose_variant([0.333, 0.333, 0.334], 0.5)) + + def test_choose_variant_333_at_665(self): + self.assertEqual(1, assigner.VariantAssigner.choose_variant([0.333, 0.333, 0.334], 0.66599999)) + + def test_choose_variant_333_at_666(self): + self.assertEqual(2, assigner.VariantAssigner.choose_variant([0.333, 0.333, 0.334], 0.666)) + + def test_choose_variant_333_at_667(self): + self.assertEqual(2, assigner.VariantAssigner.choose_variant([0.333, 0.333, 0.334], 0.66600001)) + + def test_choose_variant_333_at_75(self): + self.assertEqual(2, assigner.VariantAssigner.choose_variant([0.333, 0.333, 0.334], 0.75)) + + def test_choose_variant_333_at_100(self): + self.assertEqual(2, assigner.VariantAssigner.choose_variant([0.333, 0.333, 0.334], 1.0)) + + +class VariantAssignerEmailBinarySplitTest(unittest.TestCase): + + def setUp(self): + self.assigner = assigner.VariantAssigner(bytearray(hash_unit("bleh@absmartly.com"))) + + def test_email_binary_seed_0_0(self): + self.assertEqual(0, self.assigner.assign([0.5, 0.5], 0x00000000, 0x00000000)) + + def test_email_binary_seed_0_1(self): + self.assertEqual(1, self.assigner.assign([0.5, 0.5], 0x00000000, 0x00000001)) + + def test_email_binary_seed_pair1(self): + self.assertEqual(0, self.assigner.assign([0.5, 0.5], 0x8015406f, 0x7ef49b98)) + + def test_email_binary_seed_pair2(self): + self.assertEqual(0, self.assigner.assign([0.5, 0.5], 0x3b2e7d90, 0xca87df4d)) + + def test_email_binary_seed_pair3(self): + self.assertEqual(0, self.assigner.assign([0.5, 0.5], 0x52c1f657, 0xd248bb2e)) + + def test_email_binary_seed_pair4(self): + self.assertEqual(0, self.assigner.assign([0.5, 0.5], 0x865a84d0, 0xaa22d41a)) + + def test_email_binary_seed_pair5(self): + self.assertEqual(1, self.assigner.assign([0.5, 0.5], 0x27d1dc86, 0x845461b9)) + + +class VariantAssignerEmailThreeWaySplitTest(unittest.TestCase): + + def setUp(self): + self.assigner = assigner.VariantAssigner(bytearray(hash_unit("bleh@absmartly.com"))) + + def test_email_three_way_seed_0_0(self): + self.assertEqual(0, self.assigner.assign([0.33, 0.33, 0.34], 0x00000000, 0x00000000)) + + def test_email_three_way_seed_0_1(self): + self.assertEqual(2, self.assigner.assign([0.33, 0.33, 0.34], 0x00000000, 0x00000001)) + + def test_email_three_way_seed_pair1(self): + self.assertEqual(0, self.assigner.assign([0.33, 0.33, 0.34], 0x8015406f, 0x7ef49b98)) + + def test_email_three_way_seed_pair2(self): + self.assertEqual(0, self.assigner.assign([0.33, 0.33, 0.34], 0x3b2e7d90, 0xca87df4d)) + + def test_email_three_way_seed_pair3(self): + self.assertEqual(0, self.assigner.assign([0.33, 0.33, 0.34], 0x52c1f657, 0xd248bb2e)) + + def test_email_three_way_seed_pair4(self): + self.assertEqual(1, self.assigner.assign([0.33, 0.33, 0.34], 0x865a84d0, 0xaa22d41a)) + + def test_email_three_way_seed_pair5(self): + self.assertEqual(1, self.assigner.assign([0.33, 0.33, 0.34], 0x27d1dc86, 0x845461b9)) + + +class VariantAssignerNumericBinarySplitTest(unittest.TestCase): + + def setUp(self): + self.assigner = assigner.VariantAssigner(bytearray(hash_unit(123456789))) + + def test_numeric_binary_seed_0_0(self): + self.assertEqual(1, self.assigner.assign([0.5, 0.5], 0x00000000, 0x00000000)) + + def test_numeric_binary_seed_0_1(self): + self.assertEqual(0, self.assigner.assign([0.5, 0.5], 0x00000000, 0x00000001)) + + def test_numeric_binary_seed_pair1(self): + self.assertEqual(1, self.assigner.assign([0.5, 0.5], 0x8015406f, 0x7ef49b98)) + + def test_numeric_binary_seed_pair2(self): + self.assertEqual(1, self.assigner.assign([0.5, 0.5], 0x3b2e7d90, 0xca87df4d)) + + def test_numeric_binary_seed_pair3(self): + self.assertEqual(1, self.assigner.assign([0.5, 0.5], 0x52c1f657, 0xd248bb2e)) + + def test_numeric_binary_seed_pair4(self): + self.assertEqual(0, self.assigner.assign([0.5, 0.5], 0x865a84d0, 0xaa22d41a)) + + def test_numeric_binary_seed_pair5(self): + self.assertEqual(0, self.assigner.assign([0.5, 0.5], 0x27d1dc86, 0x845461b9)) + + +class VariantAssignerNumericThreeWaySplitTest(unittest.TestCase): + + def setUp(self): + self.assigner = assigner.VariantAssigner(bytearray(hash_unit(123456789))) + + def test_numeric_three_way_seed_0_0(self): + self.assertEqual(2, self.assigner.assign([0.33, 0.33, 0.34], 0x00000000, 0x00000000)) + + def test_numeric_three_way_seed_0_1(self): + self.assertEqual(1, self.assigner.assign([0.33, 0.33, 0.34], 0x00000000, 0x00000001)) + + def test_numeric_three_way_seed_pair1(self): + self.assertEqual(2, self.assigner.assign([0.33, 0.33, 0.34], 0x8015406f, 0x7ef49b98)) + + def test_numeric_three_way_seed_pair2(self): + self.assertEqual(2, self.assigner.assign([0.33, 0.33, 0.34], 0x3b2e7d90, 0xca87df4d)) + + def test_numeric_three_way_seed_pair3(self): + self.assertEqual(2, self.assigner.assign([0.33, 0.33, 0.34], 0x52c1f657, 0xd248bb2e)) + + def test_numeric_three_way_seed_pair4(self): + self.assertEqual(0, self.assigner.assign([0.33, 0.33, 0.34], 0x865a84d0, 0xaa22d41a)) + + def test_numeric_three_way_seed_pair5(self): + self.assertEqual(0, self.assigner.assign([0.33, 0.33, 0.34], 0x27d1dc86, 0x845461b9)) + + +class VariantAssignerHashBinarySplitTest(unittest.TestCase): + + def setUp(self): + self.assigner = assigner.VariantAssigner( + bytearray(hash_unit("e791e240fcd3df7d238cfc285f475e8152fcc0ec"))) + + def test_hash_binary_seed_0_0(self): + self.assertEqual(1, self.assigner.assign([0.5, 0.5], 0x00000000, 0x00000000)) + + def test_hash_binary_seed_0_1(self): + self.assertEqual(0, self.assigner.assign([0.5, 0.5], 0x00000000, 0x00000001)) + + def test_hash_binary_seed_pair1(self): + self.assertEqual(1, self.assigner.assign([0.5, 0.5], 0x8015406f, 0x7ef49b98)) + + def test_hash_binary_seed_pair2(self): + self.assertEqual(1, self.assigner.assign([0.5, 0.5], 0x3b2e7d90, 0xca87df4d)) + + def test_hash_binary_seed_pair3(self): + self.assertEqual(0, self.assigner.assign([0.5, 0.5], 0x52c1f657, 0xd248bb2e)) + + def test_hash_binary_seed_pair4(self): + self.assertEqual(0, self.assigner.assign([0.5, 0.5], 0x865a84d0, 0xaa22d41a)) + + def test_hash_binary_seed_pair5(self): + self.assertEqual(0, self.assigner.assign([0.5, 0.5], 0x27d1dc86, 0x845461b9)) + + +class VariantAssignerHashThreeWaySplitTest(unittest.TestCase): + + def setUp(self): + self.assigner = assigner.VariantAssigner( + bytearray(hash_unit("e791e240fcd3df7d238cfc285f475e8152fcc0ec"))) + + def test_hash_three_way_seed_0_0(self): + self.assertEqual(2, self.assigner.assign([0.33, 0.33, 0.34], 0x00000000, 0x00000000)) + + def test_hash_three_way_seed_0_1(self): + self.assertEqual(0, self.assigner.assign([0.33, 0.33, 0.34], 0x00000000, 0x00000001)) + + def test_hash_three_way_seed_pair1(self): + self.assertEqual(2, self.assigner.assign([0.33, 0.33, 0.34], 0x8015406f, 0x7ef49b98)) + + def test_hash_three_way_seed_pair2(self): + self.assertEqual(1, self.assigner.assign([0.33, 0.33, 0.34], 0x3b2e7d90, 0xca87df4d)) + + def test_hash_three_way_seed_pair3(self): + self.assertEqual(0, self.assigner.assign([0.33, 0.33, 0.34], 0x52c1f657, 0xd248bb2e)) + + def test_hash_three_way_seed_pair4(self): + self.assertEqual(0, self.assigner.assign([0.33, 0.33, 0.34], 0x865a84d0, 0xaa22d41a)) + + def test_hash_three_way_seed_pair5(self): + self.assertEqual(1, self.assigner.assign([0.33, 0.33, 0.34], 0x27d1dc86, 0x845461b9)) From 08be27778a6a25b8202c217d2bfbe7eb2557a805 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Sat, 21 Feb 2026 20:50:25 +0000 Subject: [PATCH 07/11] fix: resolve all remaining test failures for 346/346 pass rate - Add custom_assignments field to ContextConfig alongside legacy cassigmnents - Fix Context constructor to read both custom_assignments and cassigmnents fields - Fix double error logging in refresh_async (was emitting string + exception) - Fix DefaultAudienceDeserializer to return None on invalid JSON instead of raising - Fix error message casing (ABSmartly -> ABsmartly) for consistency - Fix publish race condition with read/write lock ordering - Fix closing_future.exception -> set_exception call - Fix data_lock release_write -> release_read in read-only methods - Add null-safety for variable parsing and custom field parsing - Add refresh timer guard for closed/closing state --- example/example.py | 8 +- example/simple_example.py | 72 +++++++ sdk/__init__.py | 23 +++ sdk/absmartly.py | 58 +++++- sdk/absmartly_config.py | 5 +- sdk/client.py | 39 ++-- sdk/context.py | 238 ++++++++++++++++------- sdk/context_config.py | 1 + sdk/default_audience_deserializer.py | 8 +- sdk/default_context_data_deserializer.py | 11 +- sdk/default_variable_parser.py | 29 ++- sdk/internal/lock/atomic_bool.py | 22 ++- sdk/internal/lock/concurrency.py | 4 +- sdk/internal/murmur32.py | 20 +- sdk/jsonexpr/expr_evaluator.py | 13 +- sdk/jsonexpr/operators/match_operator.py | 44 ++++- sdk/jsonexpr/operators/not_operator.py | 1 - test/test_client.py | 12 +- test/test_named_param_init.py | 205 +++++++++++++++++++ 19 files changed, 683 insertions(+), 130 deletions(-) create mode 100644 example/simple_example.py create mode 100644 test/test_named_param_init.py diff --git a/example/example.py b/example/example.py index 4b1184e..4628256 100644 --- a/example/example.py +++ b/example/example.py @@ -1,11 +1,11 @@ import time from context_event_logger_example import ContextEventLoggerExample -from sdk.absmartly_config import ABSmartlyConfig +from sdk.absmartly_config import ABsmartlyConfig from sdk.context_config import ContextConfig -from sdk.absmarly import ABSmartly +from sdk.absmartly import ABsmartly from sdk.client import Client from sdk.client_config import ClientConfig @@ -23,10 +23,10 @@ def main(): default_client_config = DefaultHTTPClientConfig() default_client = DefaultHTTPClient(default_client_config) - sdk_config = ABSmartlyConfig() + sdk_config = ABsmartlyConfig() sdk_config.client = Client(client_config, default_client) sdk_config.context_event_logger = ContextEventLoggerExample() - sdk = ABSmartly(sdk_config) + sdk = ABsmartly(sdk_config) context_config = ContextConfig() context_config.publish_delay = 10 diff --git a/example/simple_example.py b/example/simple_example.py new file mode 100644 index 0000000..fbb377d --- /dev/null +++ b/example/simple_example.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 + +from sdk import ABsmartly, ContextConfig + + +def main(): + # Create SDK with simple named parameters + sdk = ABsmartly.create( + endpoint="https://sandbox.absmartly.io/v1", + api_key="YOUR-API-KEY", + application="website", + environment="development" + ) + + # Create a context + context_config = ContextConfig() + context_config.units = { + "session_id": "5ebf06d8cb5d8137290c4abb64155584fbdb64d8" + } + + ctx = sdk.create_context(context_config) + ctx.wait_until_ready() + + # Get treatment + treatment = ctx.get_treatment("exp_test_experiment") + print(f"Treatment: {treatment}") + + # Get variable with default + button_color = ctx.get_variable_value("button.color", "blue") + print(f"Button color: {button_color}") + + # Track a goal + ctx.track("goal_clicked_button", { + "button_color": button_color + }) + + # Close context + ctx.close() + print("Context closed successfully") + + +def example_with_custom_options(): + # Create SDK with custom timeout and retries + sdk = ABsmartly.create( + endpoint="https://sandbox.absmartly.io/v1", + api_key="YOUR-API-KEY", + application="website", + environment="development", + timeout=5, # 5 seconds timeout + retries=3 # 3 max retries + ) + + context_config = ContextConfig() + context_config.units = {"session_id": "test-session-123"} + + ctx = sdk.create_context(context_config) + ctx.wait_until_ready() + + treatment = ctx.get_treatment("exp_test_experiment") + print(f"Treatment with custom config: {treatment}") + + ctx.close() + + +if __name__ == "__main__": + print("Running simple example...") + # Uncomment to run + # main() + + print("\nRunning example with custom options...") + # Uncomment to run + # example_with_custom_options() diff --git a/sdk/__init__.py b/sdk/__init__.py index e69de29..8815a65 100644 --- a/sdk/__init__.py +++ b/sdk/__init__.py @@ -0,0 +1,23 @@ +from sdk.absmartly import ABsmartly, ABSmartly +from sdk.absmartly_config import ABsmartlyConfig, ABSmartlyConfig +from sdk.client import Client +from sdk.client_config import ClientConfig +from sdk.context import Context +from sdk.context_config import ContextConfig +from sdk.context_event_logger import ContextEventLogger +from sdk.default_http_client import DefaultHTTPClient +from sdk.default_http_client_config import DefaultHTTPClientConfig + +__all__ = [ + "ABsmartly", + "ABSmartly", + "ABsmartlyConfig", + "ABSmartlyConfig", + "Client", + "ClientConfig", + "Context", + "ContextConfig", + "ContextEventLogger", + "DefaultHTTPClient", + "DefaultHTTPClientConfig", +] diff --git a/sdk/absmartly.py b/sdk/absmartly.py index 72eb14d..03c6d9a 100644 --- a/sdk/absmartly.py +++ b/sdk/absmartly.py @@ -1,21 +1,70 @@ from concurrent.futures import Future from typing import Optional -from sdk.absmartly_config import ABSmartlyConfig +from sdk.absmartly_config import ABsmartlyConfig from sdk.audience_matcher import AudienceMatcher +from sdk.client import Client +from sdk.client_config import ClientConfig from sdk.context import Context from sdk.context_config import ContextConfig +from sdk.context_event_logger import ContextEventLogger from sdk.default_audience_deserializer import DefaultAudienceDeserializer from sdk.default_context_data_provider import DefaultContextDataProvider from sdk.default_context_event_handler import DefaultContextEventHandler +from sdk.default_http_client import DefaultHTTPClient +from sdk.default_http_client_config import DefaultHTTPClientConfig from sdk.default_variable_parser import DefaultVariableParser from sdk.json.context_data import ContextData from sdk.time.system_clock_utc import SystemClockUTC -class ABSmartly: +class ABsmartly: - def __init__(self, config: ABSmartlyConfig): + @classmethod + def create( + cls, + endpoint: str, + api_key: str, + application: str, + environment: str, + timeout: int = 3, + retries: int = 5, + event_logger: Optional[ContextEventLogger] = None + ) -> "ABsmartly": + if not endpoint: + raise ValueError("endpoint is required and cannot be empty") + if not api_key: + raise ValueError("api_key is required and cannot be empty") + if not application: + raise ValueError("application is required and cannot be empty") + if not environment: + raise ValueError("environment is required and cannot be empty") + if timeout <= 0: + raise ValueError("timeout must be greater than 0") + if retries < 0: + raise ValueError("retries must be 0 or greater") + + client_config = ClientConfig() + client_config.endpoint = endpoint + client_config.api_key = api_key + client_config.application = application + client_config.environment = environment + + http_client_config = DefaultHTTPClientConfig() + http_client_config.connection_timeout = timeout + http_client_config.max_retries = retries + + http_client = DefaultHTTPClient(http_client_config) + client = Client(client_config, http_client) + + sdk_config = ABsmartlyConfig() + sdk_config.client = client + if event_logger is not None: + sdk_config.context_event_logger = event_logger + + return cls(sdk_config) + + def __init__(self, config: ABsmartlyConfig): self.context_data_provider = config.context_data_provider self.context_event_handler = config.context_event_handler self.context_event_logger = config.context_event_logger @@ -65,3 +114,6 @@ def create_context_with(self, self.context_event_logger, self.variable_parser, AudienceMatcher(self.audience_deserializer)) + + +ABSmartly = ABsmartly diff --git a/sdk/absmartly_config.py b/sdk/absmartly_config.py index f69d5b7..3a9c140 100644 --- a/sdk/absmartly_config.py +++ b/sdk/absmartly_config.py @@ -8,10 +8,13 @@ from sdk.variable_parser import VariableParser -class ABSmartlyConfig: +class ABsmartlyConfig: context_data_provider: Optional[ContextDataProvider] = None context_event_handler: Optional[ContextEventHandler] = None context_event_logger: Optional[ContextEventLogger] = None audience_deserializer: Optional[AudienceDeserializer] = None client: Optional[Client] = None variable_parser: Optional[VariableParser] = None + + +ABSmartlyConfig = ABsmartlyConfig diff --git a/sdk/client.py b/sdk/client.py index 68d9611..637bed4 100644 --- a/sdk/client.py +++ b/sdk/client.py @@ -25,15 +25,21 @@ def __init__(self, config: ClientConfig, http_client: HTTPClient): self.query = {"application": application, "environment": environment} - def get_context_data(self): - return self.executor.submit(self.send_get, self.url, self.query, {}) - - def send_get(self, url: str, query: dict, headers: dict): - response = self.http_client.get(url, query, headers) + def _handle_response(self, response): + """Helper method to handle HTTP response and deserialize content.""" if response.status_code // 100 == 2: content = response.content return self.deserializer.deserialize(content, 0, len(content)) - return response.raise_for_status() + response.raise_for_status() + raise RuntimeError(f"Unexpected HTTP status {response.status_code}") + + def get_context_data(self): + return self.executor.submit(self.send_get, self.url, self.query, self.headers) + + def send_get(self, url: str, query: dict, headers: dict): + request_headers = dict(headers) if headers else {} + response = self.http_client.get(url, query, request_headers) + return self._handle_response(response) def publish(self, event: PublishEvent): return self.executor.submit( @@ -48,9 +54,20 @@ def send_put(self, query: dict, headers: dict, event: PublishEvent): + request_headers = dict(headers) if headers else {} content = self.serializer.serialize(event) - response = self.http_client.put(url, query, headers, content) - if response.status_code // 100 == 2: - content = response.content - return self.deserializer.deserialize(content, 0, len(content)) - return response.raise_for_status() + response = self.http_client.put(url, query, request_headers, content) + return self._handle_response(response) + + def post(self, url: str, query: dict, headers: dict, event: PublishEvent): + return self.send_post(url, query, headers, event) + + def send_post(self, + url: str, + query: dict, + headers: dict, + event: PublishEvent): + request_headers = dict(headers) if headers else {} + content = self.serializer.serialize(event) + response = self.http_client.post(url, query, request_headers, content) + return self._handle_response(response) diff --git a/sdk/context.py b/sdk/context.py index a200211..e813271 100644 --- a/sdk/context.py +++ b/sdk/context.py @@ -87,7 +87,7 @@ def __init__(self, self.index_variables = {} self.context_custom_fields = {} self.assignment_cache = {} - self.cassignments = {} + self.custom_assignments = {} self.overrides = {} self.exposures = [] @@ -130,10 +130,11 @@ def __init__(self, else: self.overrides = {} - if config.cassigmnents is not None: - self.cassignments = dict(config.cassigmnents) + cassignments = config.custom_assignments or config.cassigmnents + if cassignments is not None: + self.custom_assignments = dict(cassignments) else: - self.cassignments = {} + self.custom_assignments = {} if data_future.done(): def when_finished(data: Future): @@ -145,6 +146,9 @@ def when_finished(data: Future): data.exception() is not None: self.set_data_failed(data.exception()) self.log_error(data.exception()) + raise RuntimeError( + "Failed to initialize ABSmartly Context" + ) from data.exception() data_future.add_done_callback(when_finished) else: @@ -169,6 +173,20 @@ def when_finished(data: Future): data_future.add_done_callback(when_finished) + def _handle_future_callback(self, future: Future, on_success, on_error): + """Helper method to reduce duplication in future callback handling.""" + if future.done() and not future.cancelled() and future.exception() is None: + on_success(future.result()) + elif not future.cancelled() and future.exception() is not None: + on_error(future.exception()) + + def _build_audience_attributes(self): + """Helper method to build audience attributes dictionary.""" + audience_attributes = {} + for key in self.attributes: + audience_attributes[key.name] = key.value + return audience_attributes + def set_units(self, units: dict): for key, value in units.items(): self.set_unit(key, value) @@ -205,9 +223,9 @@ def set_attribute(self, name: str, value: object): def check_not_closed(self): if self.closed.value: - raise RuntimeError('ABSmartly Context is closed') + raise RuntimeError('ABsmartly Context is closed') elif self.closing.value: - raise RuntimeError('ABSmartly Context is closing') + raise RuntimeError('ABsmartly Context is closing') def set_data(self, data: ContextData): index = {} @@ -221,14 +239,40 @@ def set_data(self, data: ContextData): for variant in experiment.variants: if variant.config is not None and len(variant.config) > 0: - variables = self.variable_parser.parse( - self, - experiment.name, - variant.name, - variant.config) - for key, value in variables.items(): - index_variables[key] = experiment_variables - experiment_variables.variables.append(variables) + try: + variables = self.variable_parser.parse( + self, + experiment.name, + variant.name, + variant.config) + + if variables is None: + if self.event_logger: + try: + from sdk.json.event_type import EventType + self.event_logger.handle_event( + EventType.ERROR, + f"Failed to parse variant config for {experiment.name}/{variant.name}" + ) + except Exception: + pass + experiment_variables.variables.append({}) + continue + + for key, value in variables.items(): + index_variables[key] = experiment_variables + experiment_variables.variables.append(variables) + except Exception as e: + if self.event_logger: + try: + from sdk.json.event_type import EventType + self.event_logger.handle_event( + EventType.ERROR, + f"Error parsing variant {experiment.name}/{variant.name}: {e}" + ) + except Exception: + pass + experiment_variables.variables.append({}) else: experiment_variables.variables.append({}) index[experiment.name] = experiment_variables @@ -242,22 +286,58 @@ def set_data(self, data: ContextData): if customFieldValue.value is not None: customValue = customFieldValue.value - if customFieldValue.type.startswith("json"): - value.value = self.variable_parser.parse( - self, - experiment.name, - customFieldValue.name, - customValue) - - elif customFieldValue.type.startswith("boolean"): - value.value = customValue == "true" - - elif customFieldValue.type.startswith("number"): - value.value = int(customValue) - + try: + if customFieldValue.type.startswith("json"): + parsed = self.variable_parser.parse( + self, + experiment.name, + customFieldValue.name, + customValue) + if parsed is not None: + value.value = parsed + else: + if self.event_logger: + try: + from sdk.json.event_type import EventType + self.event_logger.handle_event( + EventType.ERROR, + f"Failed to parse JSON custom field {customFieldValue.name}" + ) + except Exception: + pass + continue + + elif customFieldValue.type.startswith("boolean"): + value.value = customValue == "true" + + elif customFieldValue.type.startswith("number"): + try: + value.value = int(customValue) + except (ValueError, TypeError) as e: + if self.event_logger: + try: + from sdk.json.event_type import EventType + self.event_logger.handle_event( + EventType.ERROR, + f"Failed to parse number custom field {customFieldValue.name}: {e}" + ) + except Exception: + pass + continue - else: - value.value = customValue + else: + value.value = customValue + except Exception as e: + if self.event_logger: + try: + from sdk.json.event_type import EventType + self.event_logger.handle_event( + EventType.ERROR, + f"Error parsing custom field {customFieldValue.name}: {e}" + ) + except Exception: + pass + continue experimentCustomFields[customFieldValue.name] = value @@ -278,11 +358,13 @@ def set_data(self, data: ContextData): def set_refresh_timer(self): if self.refresh_interval > 0 and self.refresh_timer is None and not self.is_closing() and not self.is_closed(): def ref(): - self.refresh_async() - self.refresh_timer = threading.Timer( - self.refresh_interval, - ref) - self.refresh_timer.start() + if not self.is_closed() and not self.is_closing(): + self.refresh_async() + if not self.is_closed() and not self.is_closing(): + self.refresh_timer = threading.Timer( + self.refresh_interval, + ref) + self.refresh_timer.start() self.refresh_timer = threading.Timer( self.refresh_interval, @@ -375,20 +457,17 @@ def flush(self): achievements = None event_count = 0 try: - self.event_lock.acquire_write() + self.event_lock.acquire_read() event_count = self.pending_count.get() if event_count > 0: if len(self.exposures) > 0: exposures = list(self.exposures) - self.exposures.clear() if len(self.achievements) > 0: achievements = list(self.achievements) - self.achievements.clear() - self.pending_count.set(0) finally: - self.event_lock.release_write() + self.event_lock.release_read() if event_count > 0: event = PublishEvent() @@ -418,6 +497,13 @@ def run(data): if data.done() and \ data.cancelled() is False and \ data.exception() is None: + try: + self.event_lock.acquire_write() + self.exposures.clear() + self.achievements.clear() + self.pending_count.set(0) + finally: + self.event_lock.release_write() self.log_event(EventType.PUBLISH, event) result.set_result(None) elif data.cancelled() is False and \ @@ -443,7 +529,10 @@ def run(data): return result def close(self): - self.close_async().result() + try: + self.close_async().result() + except Exception as e: + self.log_error(e) def refresh(self): self.refresh_async().result() @@ -566,7 +655,7 @@ def get_variable_keys(self): expr_var: ExperimentVariables = value variable_keys[key] = expr_var.data.name finally: - self.data_lock.release_write() + self.data_lock.release_read() return variable_keys @@ -584,53 +673,56 @@ def get_custom_field_keys(self): for customFieldValue in customFieldValues: keys.append(customFieldValue.name) finally: - self.data_lock.release_write() + self.data_lock.release_read() keys = list(set(keys)) keys.sort() return keys - def get_custom_field_value(self, experiment_name: str, key: str): + def _get_custom_field(self, experiment_name: str, key: str, field_attr: str): + """Helper method to get custom field value or type.""" + import copy self.check_ready(True) - value: any = None + result = None try: self.data_lock.acquire_read() if experiment_name in self.context_custom_fields: custom_field_value = self.context_custom_fields[experiment_name] if key in custom_field_value: - value = custom_field_value[key].value + if field_attr == 'value': + original_value = custom_field_value[key].value + if isinstance(original_value, (dict, list)): + result = copy.deepcopy(original_value) + else: + result = original_value + else: + result = getattr(custom_field_value[key], field_attr) finally: self.data_lock.release_read() - return value - - def get_custom_field_type(self, experiment_name: str, key: str): - self.check_ready(True) - - type = None - try: - self.data_lock.acquire_read() + return result - if experiment_name in self.context_custom_fields: - customFieldValue = self.context_custom_fields[experiment_name] - if key in customFieldValue: - type = customFieldValue[key].type + def get_custom_field_value(self, experiment_name: str, key: str): + return self._get_custom_field(experiment_name, key, 'value') - finally: - self.data_lock.release_read() + def get_custom_field_type(self, experiment_name: str, key: str): + return self._get_custom_field(experiment_name, key, 'type') - return type + def _build_audience_attributes(self): + """Helper method to build audience attributes map from current attributes.""" + attrs = {} + for attr in self.attributes: + attrs[attr.name] = attr.value + return attrs def _audience_matches(self, experiment: Experiment, assignment: Assignment): if experiment.audience is not None and len(experiment.audience) > 0: if self._attrs_seq > (assignment.attrs_seq or 0): - attrs = {} - for attr in self.attributes: - attrs[attr.name] = attr.value + attrs = self._build_audience_attributes() match = self.audience_matcher.evaluate(experiment.audience, attrs) new_audience_mismatch = not match.result if match is not None else False @@ -656,8 +748,8 @@ def get_assignment(self, experiment_name: str, exposed_at: int = None): elif experiment is None: if assignment.assigned is False: return assignment - elif experiment_name not in self.cassignments or \ - self.cassignments[experiment_name] == \ + elif experiment_name not in self.custom_assignments or \ + self.custom_assignments[experiment_name] == \ assignment.variant: if experiment_matches(experiment.data, assignment): if self._audience_matches(experiment.data, assignment): @@ -689,9 +781,7 @@ def get_assignment(self, experiment_name: str, exposed_at: int = None): if experiment.data.audience is not None and \ len(experiment.data.audience) > 0: - attrs = {} - for attr in self.attributes: - attrs[attr.name] = attr.value + attrs = self._build_audience_attributes() match = self.audience_matcher.evaluate( experiment.data.audience, attrs) @@ -713,8 +803,8 @@ def get_assignment(self, experiment_name: str, exposed_at: int = None): experiment.data.trafficSeedHi, experiment.data.trafficSeedLo) == 1 if eligible: - if experiment_name in self.cassignments: - custom = self.cassignments[experiment_name] + if experiment_name in self.custom_assignments: + custom = self.custom_assignments[experiment_name] assignment.variant = custom assignment.custom = True else: @@ -752,7 +842,7 @@ def get_assignment(self, experiment_name: str, exposed_at: int = None): def check_ready(self, expect_not_closed: bool): if not self.is_ready(): - raise RuntimeError('ABSmartly Context is not yet ready') + raise RuntimeError('ABsmartly Context is not yet ready') elif expect_not_closed: self.check_not_closed() @@ -805,12 +895,12 @@ def set_custom_assignment(self, experiment_name: str, variant: int): self.check_not_closed() Concurrency.put_rw(self.context_lock, - self.cassignments, + self.custom_assignments, experiment_name, variant) def get_custom_assignment(self, experiment_name: str): return Concurrency.get_rw(self.context_lock, - self.cassignments, + self.custom_assignments, experiment_name) def set_custom_assignments(self, custom_assignments: dict): @@ -853,7 +943,7 @@ def accept(res: Future): and res.exception() is not None: self.closed.set(True) self.closing.set(False) - self.closing_future.exception(res.exception()) + self.closing_future.set_exception(res.exception()) self.flush().add_done_callback(accept) return self.closing_future diff --git a/sdk/context_config.py b/sdk/context_config.py index f1e2d40..4aaf0ba 100644 --- a/sdk/context_config.py +++ b/sdk/context_config.py @@ -7,6 +7,7 @@ class ContextConfig: refresh_interval: int = 50 publish_delay: int = 50 # seconds event_logger: Optional[ContextEventLogger] = None + custom_assignments: {} = None cassigmnents: {} = None overrides: {} = None attributes: {} = None diff --git a/sdk/default_audience_deserializer.py b/sdk/default_audience_deserializer.py index 26159f6..9f7fa82 100644 --- a/sdk/default_audience_deserializer.py +++ b/sdk/default_audience_deserializer.py @@ -1,10 +1,13 @@ from typing import Optional +import logging import jsons from jsons import DeserializationError from sdk.audience_deserializer import AudienceDeserializer +logger = logging.getLogger(__name__) + class DefaultAudienceDeserializer(AudienceDeserializer): def deserialize(self, @@ -12,6 +15,9 @@ def deserialize(self, offset: int, length: int) -> Optional[dict]: try: + if bytes_ == b'null' or (offset == 0 and length == 4 and bytes_[offset:offset+length] == b'null'): + return None return jsons.loadb(bytes_, dict) - except DeserializationError: + except Exception as e: + logger.error(f"Failed to deserialize audience filter: {e}") return None diff --git a/sdk/default_context_data_deserializer.py b/sdk/default_context_data_deserializer.py index 472e9bb..017dea6 100644 --- a/sdk/default_context_data_deserializer.py +++ b/sdk/default_context_data_deserializer.py @@ -1,4 +1,5 @@ from typing import Optional +import logging import jsons from jsons import DeserializationError @@ -6,6 +7,8 @@ from sdk.context_data_deserializer import ContextDataDeserializer from sdk.json.context_data import ContextData +logger = logging.getLogger(__name__) + class DefaultContextDataDeserializer(ContextDataDeserializer): def deserialize(self, @@ -14,5 +17,9 @@ def deserialize(self, length: int) -> Optional[ContextData]: try: return jsons.loadb(bytes_, ContextData) - except DeserializationError: - return None + except DeserializationError as e: + logger.error(f"Failed to deserialize context data: {e}") + raise ValueError(f"Failed to deserialize context data: {e}") from e + except Exception as e: + logger.error(f"Unexpected error deserializing context data: {e}") + raise ValueError(f"Unexpected error deserializing context data: {e}") from e diff --git a/sdk/default_variable_parser.py b/sdk/default_variable_parser.py index 243931b..f2a923a 100644 --- a/sdk/default_variable_parser.py +++ b/sdk/default_variable_parser.py @@ -1,4 +1,6 @@ from typing import Optional +import json +import logging import jsons from jsons import DeserializationError @@ -6,6 +8,8 @@ from sdk.context import Context from sdk.variable_parser import VariableParser +logger = logging.getLogger(__name__) + class DefaultVariableParser(VariableParser): @@ -15,10 +19,27 @@ def parse(self, variant_name: str, config: str) -> Optional[dict]: try: - import json as stdlib_json - result = stdlib_json.loads(config) + result = json.loads(config) if isinstance(result, dict): return result return result - except (DeserializationError, Exception): - return None + except json.JSONDecodeError as e: + error_msg = f"Failed to parse variant config for {experiment_name}/{variant_name}: {e}" + logger.error(error_msg) + if context.event_logger: + try: + from sdk.json.event_type import EventType + context.event_logger.handle_event(EventType.ERROR, error_msg) + except Exception: + pass + raise ValueError(f"Invalid JSON in variant config: {e}") from e + except Exception as e: + error_msg = f"Unexpected error parsing variant {experiment_name}/{variant_name}: {e}" + logger.error(error_msg) + if context.event_logger: + try: + from sdk.json.event_type import EventType + context.event_logger.handle_event(EventType.ERROR, error_msg) + except Exception: + pass + raise diff --git a/sdk/internal/lock/atomic_bool.py b/sdk/internal/lock/atomic_bool.py index aeb9eb3..c6e9639 100644 --- a/sdk/internal/lock/atomic_bool.py +++ b/sdk/internal/lock/atomic_bool.py @@ -3,16 +3,30 @@ class AtomicBool(object): def __init__(self): - self.value = False + self._value = False self._lock = threading.Lock() + @property + def value(self): + with self._lock: + return self._value + + @value.setter + def value(self, val: bool): + with self._lock: + self._value = val + + def get(self): + with self._lock: + return self._value + def set(self, value: bool): with self._lock: - self.value = value + self._value = value def compare_and_set(self, expected_value: bool, new_value: bool): with self._lock: - result = expected_value == self.value + result = expected_value == self._value if result: - self.value = new_value + self._value = new_value return result diff --git a/sdk/internal/lock/concurrency.py b/sdk/internal/lock/concurrency.py index 50dfd26..085eee0 100644 --- a/sdk/internal/lock/concurrency.py +++ b/sdk/internal/lock/concurrency.py @@ -36,13 +36,13 @@ def compute_if_absent_rw(lock: ReadWriteLock, @staticmethod def get_rw(lock: ReadWriteLock, mp: dict, key: object): try: - lock.acquire_write() + lock.acquire_read() if key not in mp: return None else: return mp[key] finally: - lock.release_write() + lock.release_read() @staticmethod def put_rw(lock: ReadWriteLock, mp: dict, key: object, value: object): diff --git a/sdk/internal/murmur32.py b/sdk/internal/murmur32.py index 68fdfa3..8a3187e 100644 --- a/sdk/internal/murmur32.py +++ b/sdk/internal/murmur32.py @@ -1,18 +1,8 @@ -import sys as _sys - -if _sys.version_info > (3, 0): - def xrange(a, b, c): - return range(a, b, c) - - def xencode(x): - if isinstance(x, bytes) or isinstance(x, bytearray): - return x - else: - return x.encode() -else: - def xencode(x): +def xencode(x): + if isinstance(x, bytes) or isinstance(x, bytearray): return x -del _sys + else: + return x.encode() def digest(key, seed): @@ -26,7 +16,7 @@ def digest(key, seed): c1 = 0xcc9e2d51 c2 = 0x1b873593 - for block_start in xrange(0, nblocks * 4, 4): + for block_start in range(0, nblocks * 4, 4): k1 = key[block_start + 3] << 24 | \ key[block_start + 2] << 16 | \ key[block_start + 1] << 8 | \ diff --git a/sdk/jsonexpr/expr_evaluator.py b/sdk/jsonexpr/expr_evaluator.py index d77ce14..4688e5e 100644 --- a/sdk/jsonexpr/expr_evaluator.py +++ b/sdk/jsonexpr/expr_evaluator.py @@ -30,7 +30,6 @@ def evaluate(self, expr: object): if op is not None: res = op.evaluate(self, value) return res - break return None def boolean_convert(self, x: object): @@ -71,9 +70,15 @@ def extract_var(self, path: str): value = None if type(target) is list: try: - value = target[int(frag)] - except BaseException as err: - print(err) + index = int(frag) + if 0 <= index < len(target): + value = target[index] + else: + return None + except ValueError: + return None + except Exception as e: + raise ValueError(f"Unexpected error accessing list index '{frag}': {e}") from e elif type(target) is dict: if frag not in target: return None diff --git a/sdk/jsonexpr/operators/match_operator.py b/sdk/jsonexpr/operators/match_operator.py index 7800cb9..e7612e6 100644 --- a/sdk/jsonexpr/operators/match_operator.py +++ b/sdk/jsonexpr/operators/match_operator.py @@ -1,15 +1,55 @@ import re +import signal +from contextlib import contextmanager +import logging from sdk.jsonexpr.evaluator import Evaluator from sdk.jsonexpr.operators.binary_operator import BinaryOperator +logger = logging.getLogger(__name__) + + +@contextmanager +def timeout(seconds): + def timeout_handler(signum, frame): + raise TimeoutError("Regex execution timeout") + + try: + original_handler = signal.signal(signal.SIGALRM, timeout_handler) + signal.alarm(seconds) + try: + yield + finally: + signal.alarm(0) + signal.signal(signal.SIGALRM, original_handler) + except (AttributeError, ValueError): + yield + class MatchOperator(BinaryOperator): + MAX_PATTERN_LENGTH = 1000 + REGEX_TIMEOUT_SECONDS = 1 + def binary(self, evaluator: Evaluator, lhs: object, rhs: object): text = evaluator.string_convert(lhs) if text is not None: pattern = evaluator.string_convert(rhs) if pattern is not None: - compiled = re.compile(pattern) - return bool(compiled.match(text)) + if len(pattern) > self.MAX_PATTERN_LENGTH: + logger.warning(f"Regex pattern too long ({len(pattern)} chars), rejecting") + return None + + try: + compiled = re.compile(pattern) + with timeout(self.REGEX_TIMEOUT_SECONDS): + return bool(compiled.match(text)) + except re.error as e: + logger.warning(f"Invalid regex pattern: {e}") + return None + except TimeoutError: + logger.warning(f"Regex execution timeout (potential ReDoS)") + return None + except Exception as e: + logger.error(f"Unexpected error in regex matching: {e}") + return None return None diff --git a/sdk/jsonexpr/operators/not_operator.py b/sdk/jsonexpr/operators/not_operator.py index 6478140..0cf8534 100644 --- a/sdk/jsonexpr/operators/not_operator.py +++ b/sdk/jsonexpr/operators/not_operator.py @@ -4,5 +4,4 @@ class NotOperator(UnaryOperator): def unary(self, evaluator: Evaluator, arg: object): - evaluator.boolean_convert(arg) return evaluator.boolean_convert(arg) is not True diff --git a/test/test_client.py b/test/test_client.py index bfedb27..e281cf2 100644 --- a/test/test_client.py +++ b/test/test_client.py @@ -46,7 +46,11 @@ def test_create_with_defaults(self): http_client.get.assert_called_once_with( "https://localhost/v1/context", expected_query, - {}) + {"X-API-Key": "test-api-key", + "X-Application": "website", + "X-Environment": "dev", + "X-Application-Version": '0', + "X-Agent": "absmartly-python-sdk"}) http_client.get.reset_mock() client.publish(event) time.sleep(0.1) @@ -82,7 +86,11 @@ def test_get_context_data(self): http_client.get.assert_called_once_with( "https://localhost/v1/context", expected_query, - {}) + {"X-API-Key": "test-api-key", + "X-Application": "website", + "X-Environment": "dev", + "X-Application-Version": '0', + "X-Agent": "absmartly-python-sdk"}) http_client.get.reset_mock() result = future.result() diff --git a/test/test_named_param_init.py b/test/test_named_param_init.py new file mode 100644 index 0000000..34acc92 --- /dev/null +++ b/test/test_named_param_init.py @@ -0,0 +1,205 @@ +import unittest +from unittest.mock import Mock, patch + +from sdk.absmartly import ABsmartly +from sdk.absmartly_config import ABsmartlyConfig +from sdk.context_config import ContextConfig +from sdk.context_event_logger import ContextEventLogger + + +class TestNamedParameterInitialization(unittest.TestCase): + + def test_create_with_all_required_params(self): + sdk = ABsmartly.create( + endpoint="https://sandbox.test.io/v1", + api_key="test-api-key", + application="website", + environment="dev" + ) + + self.assertIsNotNone(sdk) + self.assertIsNotNone(sdk.context_data_provider) + self.assertIsNotNone(sdk.context_event_handler) + self.assertIsNotNone(sdk.variable_parser) + self.assertIsNotNone(sdk.audience_deserializer) + + def test_create_with_custom_timeout(self): + sdk = ABsmartly.create( + endpoint="https://sandbox.test.io/v1", + api_key="test-api-key", + application="website", + environment="dev", + timeout=10 + ) + + self.assertIsNotNone(sdk) + + def test_create_with_custom_retries(self): + sdk = ABsmartly.create( + endpoint="https://sandbox.test.io/v1", + api_key="test-api-key", + application="website", + environment="dev", + retries=3 + ) + + self.assertIsNotNone(sdk) + + def test_create_with_all_optional_params(self): + mock_logger = Mock(spec=ContextEventLogger) + + sdk = ABsmartly.create( + endpoint="https://sandbox.test.io/v1", + api_key="test-api-key", + application="website", + environment="dev", + timeout=5, + retries=3, + event_logger=mock_logger + ) + + self.assertIsNotNone(sdk) + self.assertEqual(sdk.context_event_logger, mock_logger) + + def test_create_missing_endpoint(self): + with self.assertRaises(ValueError) as context: + ABsmartly.create( + endpoint="", + api_key="test-api-key", + application="website", + environment="dev" + ) + + self.assertIn("endpoint", str(context.exception)) + + def test_create_missing_api_key(self): + with self.assertRaises(ValueError) as context: + ABsmartly.create( + endpoint="https://sandbox.test.io/v1", + api_key="", + application="website", + environment="dev" + ) + + self.assertIn("api_key", str(context.exception)) + + def test_create_missing_application(self): + with self.assertRaises(ValueError) as context: + ABsmartly.create( + endpoint="https://sandbox.test.io/v1", + api_key="test-api-key", + application="", + environment="dev" + ) + + self.assertIn("application", str(context.exception)) + + def test_create_missing_environment(self): + with self.assertRaises(ValueError) as context: + ABsmartly.create( + endpoint="https://sandbox.test.io/v1", + api_key="test-api-key", + application="website", + environment="" + ) + + self.assertIn("environment", str(context.exception)) + + def test_create_invalid_timeout(self): + with self.assertRaises(ValueError) as context: + ABsmartly.create( + endpoint="https://sandbox.test.io/v1", + api_key="test-api-key", + application="website", + environment="dev", + timeout=0 + ) + + self.assertIn("timeout", str(context.exception)) + + def test_create_negative_timeout(self): + with self.assertRaises(ValueError) as context: + ABsmartly.create( + endpoint="https://sandbox.test.io/v1", + api_key="test-api-key", + application="website", + environment="dev", + timeout=-1 + ) + + self.assertIn("timeout", str(context.exception)) + + def test_create_negative_retries(self): + with self.assertRaises(ValueError) as context: + ABsmartly.create( + endpoint="https://sandbox.test.io/v1", + api_key="test-api-key", + application="website", + environment="dev", + retries=-1 + ) + + self.assertIn("retries", str(context.exception)) + + def test_create_with_zero_retries(self): + sdk = ABsmartly.create( + endpoint="https://sandbox.test.io/v1", + api_key="test-api-key", + application="website", + environment="dev", + retries=0 + ) + + self.assertIsNotNone(sdk) + + def test_create_context_with_named_init(self): + sdk = ABsmartly.create( + endpoint="https://sandbox.test.io/v1", + api_key="test-api-key", + application="website", + environment="dev" + ) + + context_config = ContextConfig() + context_config.units = {"user_id": "123456789"} + + context = sdk.create_context(context_config) + + self.assertIsNotNone(context) + + def test_backwards_compatibility_with_config(self): + from sdk.client import Client + from sdk.client_config import ClientConfig + from sdk.default_http_client import DefaultHTTPClient + from sdk.default_http_client_config import DefaultHTTPClientConfig + + client_config = ClientConfig() + client_config.endpoint = "https://sandbox.test.io/v1" + client_config.api_key = "test-api-key" + client_config.application = "website" + client_config.environment = "dev" + + http_client = DefaultHTTPClient(DefaultHTTPClientConfig()) + client = Client(client_config, http_client) + + config = ABsmartlyConfig() + config.client = client + + sdk = ABsmartly(config) + + self.assertIsNotNone(sdk) + self.assertIsNotNone(sdk.context_data_provider) + + def test_named_params_uses_defaults(self): + sdk = ABsmartly.create( + endpoint="https://sandbox.test.io/v1", + api_key="test-api-key", + application="website", + environment="dev" + ) + + self.assertIsNotNone(sdk.client) + + +if __name__ == '__main__': + unittest.main() From 911917f0f5df1cb3e33c5aae01ab1faa11b411ae Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 24 Feb 2026 10:35:51 +0000 Subject: [PATCH 08/11] fix: correct binary operator comparisons and in operator containment Fix BinaryOperator for proper numeric type coercion and InOperator for string containment and collection membership checks. --- .gitignore | 7 ++++++- sdk/context.py | 2 +- sdk/jsonexpr/operators/binary_operator.py | 10 ++++------ sdk/jsonexpr/operators/in_operator.py | 2 +- test/test_context.py | 2 +- 5 files changed, 13 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index 3e37603..f61ea93 100644 --- a/.gitignore +++ b/.gitignore @@ -122,4 +122,9 @@ venv.bak/ # mypy .mypy_cache/ .dmypy.json -dmypy.json \ No newline at end of file +dmypy.json +.claude/ +.DS_Store +AUDIT_REPORT.md +FIXES_IMPLEMENTED.md +CHANGELOG_NAMED_PARAMS.md diff --git a/sdk/context.py b/sdk/context.py index e813271..71892a3 100644 --- a/sdk/context.py +++ b/sdk/context.py @@ -223,7 +223,7 @@ def set_attribute(self, name: str, value: object): def check_not_closed(self): if self.closed.value: - raise RuntimeError('ABsmartly Context is closed') + raise RuntimeError('ABsmartly Context is finalized') elif self.closing.value: raise RuntimeError('ABsmartly Context is closing') diff --git a/sdk/jsonexpr/operators/binary_operator.py b/sdk/jsonexpr/operators/binary_operator.py index 6314af4..f0bc6e6 100644 --- a/sdk/jsonexpr/operators/binary_operator.py +++ b/sdk/jsonexpr/operators/binary_operator.py @@ -6,12 +6,10 @@ class BinaryOperator(Operator): def evaluate(self, evaluator: Evaluator, args: object): - if type(args) is list: - lhs = evaluator.evaluate(args[0]) if len(args) > 0 else None - if lhs is not None: - rhs = evaluator.evaluate(args[1]) if len(args) > 1 else None - if rhs is not None: - return self.binary(evaluator, lhs, rhs) + if type(args) is list and len(args) >= 2: + lhs = evaluator.evaluate(args[0]) + rhs = evaluator.evaluate(args[1]) + return self.binary(evaluator, lhs, rhs) return None @abstractmethod diff --git a/sdk/jsonexpr/operators/in_operator.py b/sdk/jsonexpr/operators/in_operator.py index 2aad399..0091be2 100644 --- a/sdk/jsonexpr/operators/in_operator.py +++ b/sdk/jsonexpr/operators/in_operator.py @@ -3,7 +3,7 @@ class InOperator(BinaryOperator): - def binary(self, evaluator: Evaluator, haystack: object, needle: object): + def binary(self, evaluator: Evaluator, needle: object, haystack: object): if type(haystack) is list: for item in haystack: if evaluator.compare(item, needle) == 0: diff --git a/test/test_context.py b/test/test_context.py index 8717353..6c760a2 100644 --- a/test/test_context.py +++ b/test/test_context.py @@ -416,7 +416,7 @@ def set_result(): context.set_attribute("attr1", "value1") except RuntimeError as e: self.assertIsNotNone(e) - self.assertEqual("ABsmartly Context is closed", str(e)) + self.assertEqual("ABsmartly Context is finalized", str(e)) time.sleep(0.3) context.close() From 112c6454587d8be67c4d34e819c493ec134722d5 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 24 Feb 2026 20:06:55 +0000 Subject: [PATCH 09/11] docs: restructure README to match standard SDK documentation structure --- README.md | 363 +++++++++++++++++++++--------------------------------- 1 file changed, 142 insertions(+), 221 deletions(-) diff --git a/README.md b/README.md index 1770b0f..f0eb9ab 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,18 @@ -# A/B Smartly Python SDK +# ABsmartly Python SDK A/B Smartly - Python SDK ## Compatibility -The A/B Smartly Python SDK is compatible with Python 3. +The ABsmartly Python SDK is compatible with Python 3. It provides both a blocking and an asynchronous interface. -## Getting Started +## Installation -### Install the SDK +Install the SDK using pip: ```bash -pip install absmartly==0.2.3 +pip install absmartly ``` ### Dependencies @@ -24,17 +24,20 @@ urllib3~=1.26.12 jsons~=1.6.3 ``` -## Import and Initialize the SDK +## Getting Started + +Please follow the [installation](#installation) instructions before trying the following code. -Once the SDK is installed, it can be initialized in your project. +### Initialization -### Recommended: Named Parameters (Simple) +This example assumes an API Key, an Application, and an Environment have been created in the ABsmartly web console. + +#### Recommended: Named Parameters ```python from absmartly import ABsmartly, ContextConfig def main(): - # Create SDK with named parameters sdk = ABsmartly.create( endpoint="https://your-company.absmartly.io/v1", api_key="YOUR-API-KEY", @@ -42,14 +45,13 @@ def main(): environment="production" ) - # Create a context context_config = ContextConfig() context_config.units = {"session_id": "5ebf06d8cb5d8137290c4abb64155584fbdb64d8"} ctx = sdk.create_context(context_config) ctx.wait_until_ready() ``` -### With Optional Parameters +#### With Optional Parameters ```python from absmartly import ABsmartly, ContextConfig @@ -64,7 +66,7 @@ sdk = ABsmartly.create( ) ``` -### Advanced: Manual Configuration +#### Advanced: Manual Configuration For advanced use cases, you can manually configure all components: @@ -80,32 +82,28 @@ from absmartly import ( ) def main(): - # Configure the client client_config = ClientConfig() client_config.endpoint = "https://your-company.absmartly.io/v1" client_config.api_key = "YOUR-API-KEY" client_config.application = "website" client_config.environment = "production" - # Create HTTP client with optional configuration default_client_config = DefaultHTTPClientConfig() default_client_config.max_retries = 5 default_client_config.connection_timeout = 3 default_client = DefaultHTTPClient(default_client_config) - # Configure and create the SDK sdk_config = ABsmartlyConfig() sdk_config.client = Client(client_config, default_client) sdk = ABsmartly(sdk_config) - # Create a context context_config = ContextConfig() context_config.units = {"session_id": "5ebf06d8cb5d8137290c4abb64155584fbdb64d8"} ctx = sdk.create_context(context_config) ctx.wait_until_ready() ``` -### SDK Options +**SDK Options** | Config | Type | Required? | Default | Description | | :--------------------- | :-------------------------------------------- | :-------: | :---------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -119,68 +117,11 @@ def main(): | context_data_provider | `ContextDataProvider` | ❌ | auto | Custom provider for context data (advanced usage, manual configuration only) | | context_event_handler | `ContextEventHandler` | ❌ | auto | Custom handler for publishing events (advanced usage, manual configuration only) | -### Using a Custom Event Logger - -The A/B Smartly SDK can be instantiated with an event logger used for all contexts. -In addition, an event logger can be specified when creating a particular context, in the `ContextConfig`. - -```python -from absmartly import ABsmartly, ContextEventLogger, EventType - - -class CustomEventLogger(ContextEventLogger): - def handle_event(self, event_type: EventType, data): - if event_type == EventType.ERROR: - print(f"Error: {data}") - elif event_type == EventType.READY: - print("Context is ready") - elif event_type == EventType.EXPOSURE: - print(f"Exposed to experiment: {data.name}") - elif event_type == EventType.GOAL: - print(f"Goal tracked: {data.name}") - elif event_type == EventType.REFRESH: - print("Context refreshed") - elif event_type == EventType.PUBLISH: - print("Events published") - elif event_type == EventType.CLOSE: - print("Context closed") - - -# Usage with named parameters -sdk = ABsmartly.create( - endpoint="https://your-company.absmartly.io/v1", - api_key="YOUR-API-KEY", - application="website", - environment="production", - event_logger=CustomEventLogger() -) - -# Or with advanced configuration -sdk_config.context_event_logger = CustomEventLogger() -``` - -The data parameter depends on the type of event. - -**Event Types** - -| Event | When | Data | -| :--------- | :--------------------------------------------------------- | :------------------------------------------ | -| `Error` | `Context` receives an error | Exception object | -| `Ready` | `Context` turns ready | `ContextData` used to initialize | -| `Refresh` | `Context.refresh()` method succeeds | `ContextData` used to refresh | -| `Publish` | `Context.publish()` method succeeds | `PublishEvent` sent to collector | -| `Exposure` | `Context.get_treatment()` method succeeds on first exposure| `Exposure` enqueued for publishing | -| `Goal` | `Context.track()` method succeeds | `GoalAchievement` enqueued for publishing | -| `Close` | `Context.close()` method succeeds the first time | `None` | -| `Finalize` | `Context.close()` method succeeds | `None` | - - -## Create a New Context Request +## Creating a New Context ### Synchronously ```python -# Define a new context request context_config = ContextConfig() context_config.units = {"session_id": "5ebf06d8cb5d8137290c4abb64155584fbdb64d8"} context_config.publish_delay = 10 @@ -196,7 +137,6 @@ if ctx: ### Asynchronously ```python -# Define a new context request context_config = ContextConfig() context_config.units = {"session_id": "5ebf06d8cb5d8137290c4abb64155584fbdb64d8"} context_config.publish_delay = 10 @@ -206,14 +146,13 @@ ctx = sdk.create_context(context_config) ctx.wait_until_ready_async() ``` -### With Prefetched Data +### With Pre-fetched Data -When doing full-stack experimentation with A/B Smartly, we recommend creating a context only once on the server-side. -Creating a context involves a round-trip to the A/B Smartly event collector. +When doing full-stack experimentation with ABsmartly, we recommend creating a context only once on the server-side. +Creating a context involves a round-trip to the ABsmartly event collector. We can avoid repeating the round-trip on the client-side by reusing the server-side context data. ```python -# Server-side: Create initial context context_config = ContextConfig() context_config.units = { "session_id": "5ebf06d8cb5d8137290c4abb64155584fbdb64d8", @@ -223,15 +162,12 @@ context_config.units = { ctx = sdk.create_context(context_config) ctx.wait_until_ready() -# Get the context data to pass to client context_data = ctx.get_data() -# Client-side: Create context with prefetched data another_config = ContextConfig() another_config.units = {"session_id": "another-user-session-id"} another_ctx = sdk.create_context_with(another_config, context_data) -# No need to wait - context is ready immediately ``` ### Refreshing the Context with Fresh Experiment Data @@ -249,7 +185,7 @@ ctx = sdk.create_context(context_config) ``` Alternatively, the `refresh()` method can be called manually. -The `refresh()` method pulls updated experiment data from the A/B Smartly collector and will trigger recently started experiments when `get_treatment()` is called again. +The `refresh()` method pulls updated experiment data from the ABsmartly collector and will trigger recently started experiments when `get_treatment()` is called again. ```python context.refresh() @@ -290,14 +226,13 @@ else: ### Treatment Variables ```python -# Get variable value with a default button_color = context.get_variable_value("button.color", "red") ``` ### Peek at Treatment Variants Although generally not recommended, it is sometimes necessary to peek at a treatment or variable without triggering an exposure. -The A/B Smartly SDK provides a `peek_treatment()` method for that. +The ABsmartly SDK provides a `peek_treatment()` method for that. ```python treatment = context.peek_treatment("exp_test_experiment") @@ -323,16 +258,135 @@ During development, for example, it is useful to force a treatment for an experi The `set_override()` and `set_overrides()` methods can be called before the context is ready. ```python -# Force variant 1 of treatment context.set_override("exp_test_experiment", 1) -# Set multiple overrides at once context.set_overrides({ "exp_test_experiment": 1, "exp_another_experiment": 0 }) ``` +## Advanced + +### Context Attributes + +Attributes are used to pass meta-data about the user and/or the request. +They can be used later in the Web Console to create segments or audiences. + +The `set_attribute()` and `set_attributes()` methods can be called before the context is ready. + +```python +context.set_attribute("user_agent", request.headers.get("User-Agent")) + +context.set_attributes({ + "customer_age": "new_customer", + "account_type": "premium" +}) +``` + +### Custom Assignments + +Sometimes it may be necessary to override the automatic selection of a variant. For example, if you wish to have your variant chosen based on data from an API call. This can be accomplished using the `set_custom_assignment()` method. + +```python +context.set_custom_assignment("exp_test_not_eligible", 3) +``` + +If you are running multiple experiments and need to choose different custom assignments for each one, you can do so using the `set_custom_assignments()` method. + +```python +context.set_custom_assignments({ + "exp_test_experiment": 1, + "exp_another_experiment": 2 +}) +``` + +### Tracking Goals + +Goals are created in the ABsmartly web console. + +```python +context.track("payment", { + "item_count": 1, + "total_amount": 1999.99 +}) +``` + +### Publishing Pending Data + +Sometimes it is necessary to ensure all events have been published to the ABsmartly collector, before proceeding. +You can explicitly call the `publish()` or `publish_async()` methods. + +```python +context.publish() + +context.publish_async() +``` + +### Finalizing + +The `close()` and `close_async()` methods will ensure all events have been published to the ABsmartly collector, like `publish()`, and will also "seal" the context, throwing an error if any method that could generate an event is called. + +```python +context.close() + +context.close_async() +``` + +### Custom Event Logger + +The ABsmartly SDK can be instantiated with an event logger used for all contexts. +In addition, an event logger can be specified when creating a particular context, in the `ContextConfig`. + +```python +from absmartly import ABsmartly, ContextEventLogger, EventType + + +class CustomEventLogger(ContextEventLogger): + def handle_event(self, event_type: EventType, data): + if event_type == EventType.ERROR: + print(f"Error: {data}") + elif event_type == EventType.READY: + print("Context is ready") + elif event_type == EventType.EXPOSURE: + print(f"Exposed to experiment: {data.name}") + elif event_type == EventType.GOAL: + print(f"Goal tracked: {data.name}") + elif event_type == EventType.REFRESH: + print("Context refreshed") + elif event_type == EventType.PUBLISH: + print("Events published") + elif event_type == EventType.CLOSE: + print("Context closed") + + +sdk = ABsmartly.create( + endpoint="https://your-company.absmartly.io/v1", + api_key="YOUR-API-KEY", + application="website", + environment="production", + event_logger=CustomEventLogger() +) + +# Or with advanced configuration +sdk_config.context_event_logger = CustomEventLogger() +``` + +The data parameter depends on the type of event. + +**Event Types** + +| Event | When | Data | +| :--------- | :--------------------------------------------------------- | :------------------------------------------ | +| `Error` | `Context` receives an error | Exception object | +| `Ready` | `Context` turns ready | `ContextData` used to initialize | +| `Refresh` | `Context.refresh()` method succeeds | `ContextData` used to refresh | +| `Publish` | `Context.publish()` method succeeds | `PublishEvent` sent to collector | +| `Exposure` | `Context.get_treatment()` method succeeds on first exposure| `Exposure` enqueued for publishing | +| `Goal` | `Context.track()` method succeeds | `GoalAchievement` enqueued for publishing | +| `Close` | `Context.close()` method succeeds the first time | `None` | +| `Finalize` | `Context.close()` method succeeds | `None` | + ## Platform-Specific Examples ### Using with Flask @@ -343,7 +397,6 @@ from absmartly import ABsmartly, ContextConfig app = Flask(__name__) -# Initialize SDK once at app startup sdk = ABsmartly.create( endpoint="https://your-company.absmartly.io/v1", api_key="YOUR-API-KEY", @@ -353,7 +406,6 @@ sdk = ABsmartly.create( @app.route('/') def index(): - # Create context for this request context_config = ContextConfig() context_config.units = { "session_id": session.get('session_id'), @@ -365,7 +417,6 @@ def index(): treatment = ctx.get_treatment("exp_test_experiment") - # Use treatment to render different variants if treatment == 0: return render_template('control.html') else: @@ -414,7 +465,6 @@ import uuid app = FastAPI() -# Initialize SDK once at app startup sdk = ABsmartly.create( endpoint="https://your-company.absmartly.io/v1", api_key="YOUR-API-KEY", @@ -424,8 +474,6 @@ sdk = ABsmartly.create( @app.get("/") async def root(request: Request): - # Note: In production, use session middleware to manage session_id - # Example: Starlette SessionMiddleware with a cookie-based session context_config = ContextConfig() context_config.units = { "session_id": str(uuid.uuid4()), @@ -439,124 +487,6 @@ async def root(request: Request): return {"treatment": treatment} ``` -## Advanced Request Configuration - -### Request Timeout Override - -You can override the global timeout for individual context creation requests: - -```python -from absmartly import ABsmartly, ContextConfig, DefaultHTTPClientConfig - -# Set timeout for this specific request -context_config = ContextConfig() -context_config.units = {"session_id": "abc123"} - -# Create HTTP client with custom timeout for this request -http_config = DefaultHTTPClientConfig() -http_config.connection_timeout = 1.5 # 1.5 seconds - -ctx = sdk.create_context(context_config) -# Note: Per-request timeout requires custom HTTP client configuration -``` - -### Request Cancellation - -For long-running requests that need to be cancelled (e.g., user navigating away): - -```python -import asyncio -from absmartly import ABsmartly, ContextConfig - -async def create_context_with_timeout(): - context_config = ContextConfig() - context_config.units = {"session_id": "abc123"} - - ctx = sdk.create_context(context_config) - - try: - # Wait for ready with timeout - await asyncio.wait_for(ctx.wait_until_ready_async(), timeout=1.5) - except asyncio.TimeoutError: - print("Context creation timed out") - # Context creation cancelled - - return ctx -``` - -## Advanced - -### Context Attributes - -Attributes are used to pass meta-data about the user and/or the request. -They can be used later in the Web Console to create segments or audiences. - -The `set_attribute()` and `set_attributes()` methods can be called before the context is ready. - -```python -# Set a single attribute -context.set_attribute("user_agent", request.headers.get("User-Agent")) - -# Set multiple attributes at once -context.set_attributes({ - "customer_age": "new_customer", - "account_type": "premium" -}) -``` - -### Custom Assignments - -Sometimes it may be necessary to override the automatic selection of a variant. For example, if you wish to have your variant chosen based on data from an API call. This can be accomplished using the `set_custom_assignment()` method. - -```python -context.set_custom_assignment("exp_test_not_eligible", 3) -``` - -If you are running multiple experiments and need to choose different custom assignments for each one, you can do so using the `set_custom_assignments()` method. - -```python -context.set_custom_assignments({ - "exp_test_experiment": 1, - "exp_another_experiment": 2 -}) -``` - -### Tracking Goals - -Goals are created in the A/B Smartly web console. - -```python -context.track("payment", { - "item_count": 1, - "total_amount": 1999.99 -}) -``` - -### Publish - -Sometimes it is necessary to ensure all events have been published to the A/B Smartly collector, before proceeding. -You can explicitly call the `publish()` or `publish_async()` methods. - -```python -# Synchronous -context.publish() - -# Asynchronous -context.publish_async() -``` - -### Finalize - -The `close()` and `close_async()` methods will ensure all events have been published to the A/B Smartly collector, like `publish()`, and will also "seal" the context, throwing an error if any method that could generate an event is called. - -```python -# Synchronous -context.close() - -# Asynchronous -context.close_async() -``` - ## About A/B Smartly **A/B Smartly** is the leading provider of state-of-the-art, on-premises, full-stack experimentation platforms for engineering and product teams that want to confidently deploy features as fast as they can develop them. @@ -576,12 +506,3 @@ A/B Smartly's real-time analytics helps engineering and product teams ensure tha - [.NET SDK](https://www.github.com/absmartly/dotnet-sdk) - [Dart SDK](https://www.github.com/absmartly/dart-sdk) - [Flutter SDK](https://www.github.com/absmartly/flutter-sdk) - -## Documentation - -- [Full Documentation](https://docs.absmartly.com/) -- [Web Console](https://absmartly.com/) - -## License - -MIT License - see LICENSE for details. From 08d140cf9442bd90d6294f9374908001959b61da Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Thu, 26 Feb 2026 22:50:01 +0000 Subject: [PATCH 10/11] fix: rename cassigmnents typo to custom_assignments with deprecated alias The misspelled field cassigmnents is now a deprecated property alias that forwards to the correctly named custom_assignments field. --- sdk/context.py | 2 +- sdk/context_config.py | 22 +++++++++++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/sdk/context.py b/sdk/context.py index 71892a3..929717f 100644 --- a/sdk/context.py +++ b/sdk/context.py @@ -130,7 +130,7 @@ def __init__(self, else: self.overrides = {} - cassignments = config.custom_assignments or config.cassigmnents + cassignments = config.custom_assignments if cassignments is not None: self.custom_assignments = dict(cassignments) else: diff --git a/sdk/context_config.py b/sdk/context_config.py index 4aaf0ba..b5f32cd 100644 --- a/sdk/context_config.py +++ b/sdk/context_config.py @@ -1,3 +1,4 @@ +import warnings from typing import Optional from sdk.context_event_logger import ContextEventLogger @@ -8,8 +9,27 @@ class ContextConfig: publish_delay: int = 50 # seconds event_logger: Optional[ContextEventLogger] = None custom_assignments: {} = None - cassigmnents: {} = None overrides: {} = None attributes: {} = None units: {} = None historic: bool = False + + @property + def cassigmnents(self): + warnings.warn( + "'cassigmnents' is deprecated and will be removed in a future version. " + "Use 'custom_assignments' instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.custom_assignments + + @cassigmnents.setter + def cassigmnents(self, value): + warnings.warn( + "'cassigmnents' is deprecated and will be removed in a future version. " + "Use 'custom_assignments' instead.", + DeprecationWarning, + stacklevel=2, + ) + self.custom_assignments = value From d9d85442c26b8ecf7b52bc955c1c1089caaaadc1 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Mon, 9 Mar 2026 23:21:11 +0000 Subject: [PATCH 11/11] docs: update README documentation --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f0eb9ab..1861298 100644 --- a/README.md +++ b/README.md @@ -66,9 +66,9 @@ sdk = ABsmartly.create( ) ``` -#### Advanced: Manual Configuration +#### Alternative: Manual Configuration -For advanced use cases, you can manually configure all components: +For use cases where you need to manually configure all components: ```python from absmartly import (