From f38e049786b946154bd7008bf48fae4d53a71cbe Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Mon, 19 Jan 2026 18:14:22 +0000 Subject: [PATCH 01/21] 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/21] 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/21] 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/21] 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/21] 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/21] =?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/21] 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/21] 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/21] 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/21] 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/21] 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 ( From d1417f7e944aa353ea07fdd578bf0a8745f7a508 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Sun, 15 Mar 2026 16:25:33 +0000 Subject: [PATCH 12/21] fix: correct get_variable_keys return type to list of experiment names --- sdk/context.py | 162 +++++++++++-------------- test/test_context.py | 274 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 331 insertions(+), 105 deletions(-) diff --git a/sdk/context.py b/sdk/context.py index 929717f..7455773 100644 --- a/sdk/context.py +++ b/sdk/context.py @@ -1,5 +1,6 @@ import base64 import collections +import copy import hashlib import threading from concurrent.futures import Future @@ -96,6 +97,7 @@ def __init__(self, self.data: Optional[ContextData] = None self.failed = False + self.close_error = None self.closed = AtomicBool() self.closing = AtomicBool() @@ -159,7 +161,6 @@ def when_finished(data: Future): data.exception() is None: self.set_data(data.result()) self.ready_future.set_result(None) - self.ready_future = None self.log_event(EventType.READY, data.result()) if self.get_pending_count() > 0: @@ -168,25 +169,10 @@ def when_finished(data: Future): data.exception() is not None: self.set_data_failed(data.exception()) self.ready_future.set_result(None) - self.ready_future = None self.log_error(data.exception()) 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) @@ -218,8 +204,12 @@ def set_attribute(self, name: str, value: object): attribute.name = name attribute.value = value attribute.setAt = self.clock.millis() - Concurrency.add_rw(self.context_lock, self.attributes, attribute) - self._attrs_seq += 1 + try: + self.context_lock.acquire_write() + self.attributes.append(attribute) + self._attrs_seq += 1 + finally: + self.context_lock.release_write() def check_not_closed(self): if self.closed.value: @@ -247,31 +237,25 @@ def set_data(self, data: ContextData): 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 + self.log_event( + EventType.ERROR, + f"Failed to parse variant config for {experiment.name}/{variant.name}" + ) experiment_variables.variables.append({}) continue for key, value in variables.items(): - index_variables[key] = experiment_variables + if key in index_variables: + if experiment_variables not in index_variables[key]: + index_variables[key].append(experiment_variables) + else: + 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 + self.log_event( + EventType.ERROR, + f"Error parsing variant {experiment.name}/{variant.name}: {e}" + ) experiment_variables.variables.append({}) else: experiment_variables.variables.append({}) @@ -288,23 +272,14 @@ def set_data(self, data: ContextData): 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 + try: + import json as _json + value.value = _json.loads(customValue) + except (ValueError, TypeError): + self.log_event( + EventType.ERROR, + f"Failed to parse JSON custom field {customFieldValue.name}" + ) continue elif customFieldValue.type.startswith("boolean"): @@ -314,29 +289,19 @@ def set_data(self, data: ContextData): 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 + self.log_event( + EventType.ERROR, + f"Failed to parse number custom field {customFieldValue.name}: {e}" + ) continue 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 + self.log_event( + EventType.ERROR, + f"Error parsing custom field {customFieldValue.name}: {e}" + ) continue experimentCustomFields[customFieldValue.name] = value @@ -457,17 +422,23 @@ def flush(self): achievements = None event_count = 0 try: - self.event_lock.acquire_read() + self.event_lock.acquire_write() 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( + self.pending_count.get() - event_count + ) finally: - self.event_lock.release_read() + self.event_lock.release_write() if event_count > 0: event = PublishEvent() @@ -497,17 +468,21 @@ 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 \ data.exception() is not None: + try: + self.event_lock.acquire_write() + if exposures: + self.exposures = exposures + self.exposures + if achievements: + self.achievements = achievements + self.achievements + self.pending_count.set( + self.pending_count.get() + event_count + ) + finally: + self.event_lock.release_write() self.log_error(data.exception()) result.set_exception(data.exception()) @@ -532,6 +507,7 @@ def close(self): try: self.close_async().result() except Exception as e: + self.close_error = e self.log_error(e) def refresh(self): @@ -594,9 +570,13 @@ def clear_timeout(self): self.timeout_lock.release_write() def clear_refresh_timer(self): - if self.refresh_timer is not None: - self.refresh_timer.cancel() - self.refresh_timer = None + try: + self.timeout_lock.acquire_write() + if self.refresh_timer is not None: + self.refresh_timer.cancel() + self.refresh_timer = None + finally: + self.timeout_lock.release_write() def get_variable_value(self, key: str, default_value: object): self.check_ready(True) @@ -651,9 +631,8 @@ def get_variable_keys(self): variable_keys = {} try: self.data_lock.acquire_read() - for key, value in self.index_variables.items(): - expr_var: ExperimentVariables = value - variable_keys[key] = expr_var.data.name + for key, experiments in self.index_variables.items(): + variable_keys[key] = [expr_var.data.name for expr_var in experiments] finally: self.data_lock.release_read() @@ -681,8 +660,6 @@ def get_custom_field_keys(self): return keys 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) result = None @@ -876,8 +853,6 @@ def get_data(self): self.data_lock.release_read() def set_override(self, experiment_name: str, variant: int): - self.check_not_closed() - return Concurrency.put_rw(self.context_lock, self.overrides, experiment_name, variant) @@ -916,7 +891,10 @@ def apply(key: str): unit_type, apply) def get_variable_experiment(self, key: str): - return Concurrency.get_rw(self.data_lock, self.index_variables, key) + experiments = Concurrency.get_rw(self.data_lock, self.index_variables, key) + if experiments: + return experiments[0] + return None def get_variable_assignment(self, key: str): experiment: ExperimentVariables = self.get_variable_experiment(key) diff --git a/test/test_context.py b/test/test_context.py index 6c760a2..e6e8655 100644 --- a/test/test_context.py +++ b/test/test_context.py @@ -91,13 +91,13 @@ class ContextTest(unittest.TestCase): } variableExperiments = { - "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", + "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"], } units = { @@ -686,10 +686,10 @@ def test_peek_variable(self): self.assertEqual(True, context.is_ready()) self.assertEqual(False, context.is_failed()) - for key, value in self.variableExperiments.items(): + for key, experiments in self.variableExperiments.items(): res = context.peek_variable_value(key, 17) - if value != "exp_test_not_eligible" and \ - string_in_list(value, self.data.experiments): + if experiments[0] != "exp_test_not_eligible" and \ + string_in_list(experiments[0], self.data.experiments): self.assertEqual(self.expectedVariables[key], res) else: self.assertEqual(17, res) @@ -719,10 +719,10 @@ def test_get_variable(self): self.assertEqual(True, context.is_ready()) self.assertEqual(False, context.is_failed()) - for key, value in self.variableExperiments.items(): + for key, experiments in self.variableExperiments.items(): res = context.get_variable_value(key, 17) - if value != "exp_test_not_eligible" and \ - string_in_list(value, self.data.experiments): + if experiments[0] != "exp_test_not_eligible" and \ + string_in_list(experiments[0], self.data.experiments): self.assertEqual(self.expectedVariables[key], res) else: self.assertEqual(17, res) @@ -1513,3 +1513,251 @@ def failing_get_context_data(): self.assertEqual(1, len(error_events)) self.assertIsInstance(error_events[0], RuntimeError) context.close() + + def test_flush_atomically_clears_events(self): + self.set_up() + config = ContextConfig() + config.units = self.units + context = self.create_test_context(config, self.data_future_ready) + + context.track("goal1", {"amount": 100}) + self.assertEqual(1, context.get_pending_count()) + + published_events = [] + original_publish = self.client.publish + + def capturing_publish(event): + published_events.append(event) + context.track("goal2", {"amount": 200}) + return original_publish(event) + + self.client.publish = capturing_publish + + context.flush() + + self.assertEqual(1, len(published_events)) + self.assertEqual(1, len(published_events[0].goals)) + self.assertEqual("goal1", published_events[0].goals[0].name) + self.assertEqual(1, context.get_pending_count()) + context.close() + + def test_flush_restores_events_on_failure(self): + self.set_up() + config = ContextConfig() + config.units = self.units + context = self.create_test_context(config, self.data_future_ready) + + context.track("goal1", {"amount": 100}) + self.assertEqual(1, context.get_pending_count()) + + def failing_publish(event): + future = Future() + future.set_exception(RuntimeError("Publish failed")) + return future + + self.client.publish = failing_publish + + try: + context.publish() + except RuntimeError: + pass + + self.assertEqual(1, context.get_pending_count()) + self.assertEqual(1, len(context.achievements)) + self.assertEqual("goal1", context.achievements[0].name) + + self.client.publish = lambda event: ( + lambda f=Future(): (f.set_result(None), f)[1] + )() + context.close() + + def test_close_sets_close_error_on_failure(self): + self.set_up() + config = ContextConfig() + config.units = self.units + context = self.create_test_context(config, self.data_future_ready) + + context.track("goal1", {"amount": 100}) + + def failing_publish(event): + future = Future() + future.set_exception(RuntimeError("Publish failed")) + return future + + self.client.publish = failing_publish + context.close() + + self.assertIsNotNone(context.close_error) + self.assertIsInstance(context.close_error, RuntimeError) + self.assertEqual("Publish failed", str(context.close_error)) + + def test_close_no_error_on_success(self): + self.set_up() + config = ContextConfig() + config.units = self.units + context = self.create_test_context(config, self.data_future_ready) + + context.track("goal1", {"amount": 100}) + context.close() + + self.assertIsNone(context.close_error) + + def test_set_attribute_increments_attrs_seq_atomically(self): + self.set_up() + config = ContextConfig() + config.units = self.units + context = self.create_test_context(config, self.data_future_ready) + + self.assertEqual(0, context._attrs_seq) + context.set_attribute("attr1", "value1") + self.assertEqual(1, context._attrs_seq) + context.set_attribute("attr2", "value2") + self.assertEqual(2, context._attrs_seq) + + self.assertEqual(2, len(context.attributes)) + context.close() + + def test_ready_future_not_set_to_none(self): + self.set_up() + config = ContextConfig() + config.units = self.units + + context = self.create_test_context(config, self.data_future) + self.data_future.set_result(self.data) + + self.assertTrue(context.is_ready()) + self.assertIsNotNone(context.ready_future) + self.assertTrue(context.ready_future.done()) + context.close() + + def test_get_variable_keys_returns_list_of_experiments(self): + self.set_up() + config = ContextConfig() + config.units = self.units + context = self.create_test_context(config, self.data_future_refresh) + self.assertTrue(context.is_ready()) + + res = context.get_variable_keys() + + for key, experiments in res.items(): + self.assertIsInstance(experiments, list) + self.assertGreater(len(experiments), 0) + + context.close() + + def test_get_variable_keys_multiple_experiments_share_key(self): + self.set_up() + + import json + from sdk.json.context_data import ContextData + from sdk.json.experiment import Experiment + from sdk.json.experiment_variant import ExperimentVariant + + exp1 = Experiment() + exp1.id = 1 + exp1.name = "exp_a" + exp1.unitType = "user_id" + exp1.iteration = 1 + exp1.fullOnVariant = 0 + exp1.trafficSplit = [0.0, 1.0] + exp1.trafficSeedHi = 0 + exp1.trafficSeedLo = 0 + exp1.audience = "" + exp1.audienceStrict = False + exp1.split = [0.5, 0.5] + exp1.seedHi = 0 + exp1.seedLo = 0 + exp1.customFieldValues = None + + v1 = ExperimentVariant() + v1.name = "control" + v1.config = None + v2 = ExperimentVariant() + v2.name = "treatment" + v2.config = json.dumps({"shared_key": "value_a"}) + exp1.variants = [v1, v2] + + exp2 = Experiment() + exp2.id = 2 + exp2.name = "exp_b" + exp2.unitType = "user_id" + exp2.iteration = 1 + exp2.fullOnVariant = 0 + exp2.trafficSplit = [0.0, 1.0] + exp2.trafficSeedHi = 0 + exp2.trafficSeedLo = 0 + exp2.audience = "" + exp2.audienceStrict = False + exp2.split = [0.5, 0.5] + exp2.seedHi = 0 + exp2.seedLo = 0 + exp2.customFieldValues = None + + v3 = ExperimentVariant() + v3.name = "control" + v3.config = None + v4 = ExperimentVariant() + v4.name = "treatment" + v4.config = json.dumps({"shared_key": "value_b"}) + exp2.variants = [v3, v4] + + data = ContextData() + data.experiments = [exp1, exp2] + future = Future() + future.set_result(data) + + config = ContextConfig() + config.units = self.units + context = self.create_test_context(config, future) + self.assertTrue(context.is_ready()) + + res = context.get_variable_keys() + + self.assertIn("shared_key", res) + self.assertIsInstance(res["shared_key"], list) + self.assertEqual(2, len(res["shared_key"])) + self.assertIn("exp_a", res["shared_key"]) + self.assertIn("exp_b", res["shared_key"]) + + context.close() + + def test_set_override_allowed_after_close(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()) + + context.close() + self.assertTrue(context.is_closed()) + + context.set_override("exp_test_ab", 1) + self.assertEqual(1, context.get_override("exp_test_ab")) + + def test_set_overrides_allowed_after_close(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()) + + context.close() + self.assertTrue(context.is_closed()) + + context.set_overrides({"exp_test_ab": 2, "exp_test_abc": 1}) + self.assertEqual(2, context.get_override("exp_test_ab")) + self.assertEqual(1, context.get_override("exp_test_abc")) + + def test_clear_refresh_timer_thread_safe(self): + self.set_up() + config = ContextConfig() + config.units = self.units + context = self.create_test_context(config, self.data_future_ready) + + context.refresh_timer = threading.Timer(999, lambda: None) + context.clear_refresh_timer() + self.assertIsNone(context.refresh_timer) + + context.clear_refresh_timer() + self.assertIsNone(context.refresh_timer) + context.close() From e2addb096d25e176345c3606c2353f5ad4420c58 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Sun, 15 Mar 2026 16:50:47 +0000 Subject: [PATCH 13/21] feat: add get_unit, get_units, get_attribute, get_attributes, and ready_error to Context --- sdk/context.py | 33 +++++++++++++++++ test/test_context.py | 87 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+) diff --git a/sdk/context.py b/sdk/context.py index 7455773..f097317 100644 --- a/sdk/context.py +++ b/sdk/context.py @@ -98,6 +98,7 @@ def __init__(self, self.failed = False self.close_error = None + self.ready_error = None self.closed = AtomicBool() self.closing = AtomicBool() @@ -194,10 +195,41 @@ def set_unit(self, unit_type: str, uid: str): finally: self.context_lock.release_write() + def get_unit(self, unit_type: str): + return Concurrency.get_rw(self.context_lock, self.units, unit_type) + + def get_units(self): + try: + self.context_lock.acquire_read() + return dict(self.units) + finally: + self.context_lock.release_read() + def set_attributes(self, attributes: dict): for key, value in attributes.items(): self.set_attribute(key, value) + def get_attribute(self, name: str): + try: + self.context_lock.acquire_read() + result = None + for attr in self.attributes: + if attr.name == name: + result = attr.value + return result + finally: + self.context_lock.release_read() + + def get_attributes(self): + try: + self.context_lock.acquire_read() + result = {} + for attr in self.attributes: + result[attr.name] = attr.value + return result + finally: + self.context_lock.release_read() + def set_attribute(self, name: str, value: object): self.check_not_closed() attribute = Attribute() @@ -399,6 +431,7 @@ def set_data_failed(self, exception): self.index_variables = {} self.data = ContextData() self.failed = True + self.ready_error = exception finally: self.data_lock.release_write() diff --git a/test/test_context.py b/test/test_context.py index e6e8655..9f8ee93 100644 --- a/test/test_context.py +++ b/test/test_context.py @@ -1761,3 +1761,90 @@ def test_clear_refresh_timer_thread_safe(self): context.clear_refresh_timer() self.assertIsNone(context.refresh_timer) context.close() + + def test_get_unit_returns_unit(self): + self.set_up() + config = ContextConfig() + config.units = self.units + context = self.create_test_context(config, self.data_future_ready) + + self.assertEqual("e791e240fcd3df7d238cfc285f475e8152fcc0ec", context.get_unit("session_id")) + self.assertEqual("123456789", context.get_unit("user_id")) + self.assertEqual("bleh@absmartly.com", context.get_unit("email")) + self.assertIsNone(context.get_unit("nonexistent")) + context.close() + + def test_get_units_returns_all_units(self): + self.set_up() + config = ContextConfig() + config.units = self.units + context = self.create_test_context(config, self.data_future_ready) + + result = context.get_units() + self.assertEqual(self.units, result) + context.close() + + def test_get_attribute_returns_last_set_value(self): + self.set_up() + config = ContextConfig() + config.units = self.units + context = self.create_test_context(config, self.data_future_ready) + + context.set_attribute("country", "US") + context.set_attribute("language", "en") + context.set_attribute("country", "DE") + + self.assertEqual("DE", context.get_attribute("country")) + self.assertEqual("en", context.get_attribute("language")) + self.assertIsNone(context.get_attribute("nonexistent")) + context.close() + + def test_get_attributes_returns_all_attributes(self): + self.set_up() + config = ContextConfig() + config.units = self.units + context = self.create_test_context(config, self.data_future_ready) + + context.set_attribute("country", "US") + context.set_attribute("language", "en") + context.set_attribute("country", "DE") + + result = context.get_attributes() + self.assertEqual("DE", result["country"]) + self.assertEqual("en", result["language"]) + context.close() + + def test_ready_error_is_none_on_success(self): + self.set_up() + config = ContextConfig() + config.units = self.units + context = self.create_test_context(config, self.data_future_ready) + + self.assertIsNone(context.ready_error) + self.assertFalse(context.is_failed()) + context.close() + + def test_ready_error_set_on_failed_future(self): + self.set_up() + config = ContextConfig() + config.units = self.units + context = self.create_test_context(config, self.data_future_failed) + + self.assertTrue(context.is_failed()) + self.assertIsNotNone(context.ready_error) + context.close() + + def test_ready_error_set_after_async_failure(self): + self.set_up() + config = ContextConfig() + config.units = self.units + context = self.create_test_context(config, self.data_future) + + self.assertFalse(context.is_ready()) + error = RuntimeError("FAILED") + self.data_future.set_exception(error) + context.wait_until_ready() + + self.assertTrue(context.is_failed()) + self.assertIsNotNone(context.ready_error) + context.close() From 4e16467cf0128819889bd0141784f77585fc5f49 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Sun, 15 Mar 2026 18:04:17 +0000 Subject: [PATCH 14/21] feat: add finalize aliases, standardize error messages, rename get_custom_field_type - Add finalize(), finalize_async(), is_finalized(), is_finalizing() as aliases - Rename get_custom_field_type() to get_custom_field_value_type(), keep old as alias - Standardize error messages: include unit type in unit errors, add trailing periods --- sdk/context.py | 27 +++++++++++++++++++++------ test/test_context.py | 12 ++++++------ 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/sdk/context.py b/sdk/context.py index f097317..504f360 100644 --- a/sdk/context.py +++ b/sdk/context.py @@ -185,11 +185,11 @@ def set_unit(self, unit_type: str, uid: str): self.context_lock.acquire_write() if unit_type in self.units.keys() and self.units[unit_type] != uid: - raise ValueError("Unit already set.") + raise ValueError(f"Unit '{unit_type}' UID already set.") trimmed = uid.strip() if len(trimmed) == 0: - raise ValueError("Unit UID must not be blank.") + raise ValueError(f"Unit '{unit_type}' UID must not be blank.") self.units[unit_type] = trimmed finally: @@ -245,9 +245,9 @@ def set_attribute(self, name: str, value: object): def check_not_closed(self): if self.closed.value: - raise RuntimeError('ABsmartly Context is finalized') + raise RuntimeError('ABsmartly Context is finalized.') elif self.closing.value: - raise RuntimeError('ABsmartly Context is closing') + raise RuntimeError('ABsmartly Context is finalizing.') def set_data(self, data: ContextData): index = {} @@ -391,9 +391,15 @@ def is_failed(self): def is_closed(self): return self.closed.value + def is_finalized(self): + return self.is_closed() + def is_closing(self): return not self.closed.value and self.closing.value + def is_finalizing(self): + return self.is_closing() + def refresh_async(self): self.check_not_closed() @@ -543,6 +549,12 @@ def close(self): self.close_error = e self.log_error(e) + def finalize(self): + return self.close() + + def finalize_async(self): + return self.close_async() + def refresh(self): self.refresh_async().result() @@ -719,9 +731,12 @@ def _get_custom_field(self, experiment_name: str, key: str, field_attr: str): def get_custom_field_value(self, experiment_name: str, key: str): return self._get_custom_field(experiment_name, key, 'value') - def get_custom_field_type(self, experiment_name: str, key: str): + def get_custom_field_value_type(self, experiment_name: str, key: str): return self._get_custom_field(experiment_name, key, 'type') + def get_custom_field_type(self, experiment_name: str, key: str): + return self.get_custom_field_value_type(experiment_name, key) + def _build_audience_attributes(self): """Helper method to build audience attributes map from current attributes.""" attrs = {} @@ -852,7 +867,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() diff --git a/test/test_context.py b/test/test_context.py index 9f8ee93..c1f0d52 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 finalizing.", 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 finalized", str(e)) + self.assertEqual("ABsmartly Context is finalized.", str(e)) time.sleep(0.3) context.close() @@ -477,12 +477,12 @@ def test_unit_empty(self): try: context.set_unit("db_user_id", "") except ValueError as e: - self.assertEqual("Unit UID must not be blank.", str(e)) + self.assertEqual("Unit 'db_user_id' UID must not be blank.", str(e)) try: context.set_unit("session_id", "1") except ValueError as e: - self.assertEqual("Unit already set.", str(e)) + self.assertEqual("Unit 'session_id' UID already set.", str(e)) context.close() @@ -1370,7 +1370,7 @@ def test_set_unit_empty_throws(self): with self.assertRaises(ValueError) as ctx: context.set_unit("device_id", "") - self.assertEqual("Unit UID must not be blank.", str(ctx.exception)) + self.assertEqual("Unit 'device_id' UID must not be blank.", str(ctx.exception)) context.close() def test_set_unit_duplicate_throws(self): @@ -1383,7 +1383,7 @@ def test_set_unit_duplicate_throws(self): with self.assertRaises(ValueError) as ctx: context.set_unit("user_id", "different-value") - self.assertEqual("Unit already set.", str(ctx.exception)) + self.assertEqual("Unit 'user_id' UID already set.", str(ctx.exception)) context.close() def test_set_units_batch(self): From b1155a05f1cf2f6b903e78e6f75a2f1c091e6906 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Sun, 15 Mar 2026 20:46:37 +0000 Subject: [PATCH 15/21] fix(python): return safe defaults from read methods when context not ready or finalized get_treatment/peek_treatment return 0, get_variable_value/peek_variable_value return default_value, get_variable_keys returns {}, get_experiments returns [], custom field methods return None/[]. Write methods (track, set_unit, set_attribute) keep throwing. --- sdk/context.py | 24 ++++++++++++++++-------- test/test_context.py | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/sdk/context.py b/sdk/context.py index 504f360..9c1a1e9 100644 --- a/sdk/context.py +++ b/sdk/context.py @@ -624,7 +624,8 @@ def clear_refresh_timer(self): self.timeout_lock.release_write() def get_variable_value(self, key: str, default_value: object): - self.check_ready(True) + if not self.is_ready() or self.is_closed() or self.is_closing(): + return default_value assignment = self.get_variable_assignment(key) if assignment is not None: @@ -637,7 +638,8 @@ def get_variable_value(self, key: str, default_value: object): return default_value def peek_variable_value(self, key: str, default_value: object): - self.check_ready(True) + if not self.is_ready() or self.is_closed() or self.is_closing(): + return default_value assignment = self.get_variable_assignment(key) if assignment is not None: @@ -647,7 +649,8 @@ def peek_variable_value(self, key: str, default_value: object): return default_value def peek_treatment(self, experiment_name: str): - self.check_ready(True) + if not self.is_ready() or self.is_closed() or self.is_closing(): + return 0 return self.get_assignment(experiment_name).variant @@ -664,14 +667,16 @@ def computer(key: str): computer) def get_treatment(self, experiment_name: str, exposed_at: int = None): - self.check_ready(True) + if not self.is_ready() or self.is_closed() or self.is_closing(): + return 0 assignment = self.get_assignment(experiment_name, exposed_at=exposed_at) if not assignment.exposed.value: self.queue_exposure(assignment) return assignment.variant def get_variable_keys(self): - self.check_ready(True) + if not self.is_ready() or self.is_closed() or self.is_closing(): + return {} variable_keys = {} try: @@ -684,7 +689,8 @@ def get_variable_keys(self): return variable_keys def get_custom_field_keys(self): - self.check_ready(True) + if not self.is_ready() or self.is_closed() or self.is_closing(): + return [] keys = [] try: @@ -705,7 +711,8 @@ def get_custom_field_keys(self): return keys def _get_custom_field(self, experiment_name: str, key: str, field_attr: str): - self.check_ready(True) + if not self.is_ready() or self.is_closed() or self.is_closing(): + return None result = None try: @@ -879,7 +886,8 @@ def get_experiment(self, experiment_name: str): self.data_lock.release_read() def get_experiments(self): - self.check_ready(True) + if not self.is_ready() or self.is_closed() or self.is_closing(): + return [] try: self.data_lock.acquire_read() diff --git a/test/test_context.py b/test/test_context.py index c1f0d52..ac81bf2 100644 --- a/test/test_context.py +++ b/test/test_context.py @@ -1848,3 +1848,46 @@ def test_ready_error_set_after_async_failure(self): self.assertTrue(context.is_failed()) self.assertIsNotNone(context.ready_error) context.close() + + def test_read_methods_return_safe_defaults_when_not_ready(self): + self.set_up() + config = ContextConfig() + config.units = self.units + context = self.create_test_context(config, self.data_future) + + self.assertFalse(context.is_ready()) + + self.assertEqual(0, context.get_treatment("exp_test_ab")) + self.assertEqual(0, context.peek_treatment("exp_test_ab")) + self.assertEqual(17, context.get_variable_value("banner.size", 17)) + self.assertEqual(17, context.peek_variable_value("banner.size", 17)) + self.assertEqual({}, context.get_variable_keys()) + self.assertEqual([], context.get_experiments()) + self.assertIsNone(context.get_custom_field_value("exp_test_abc", "country")) + self.assertIsNone(context.get_custom_field_value_type("exp_test_abc", "country")) + self.assertEqual([], context.get_custom_field_keys()) + + self.data_future.set_result(self.data) + + def test_read_methods_return_safe_defaults_after_finalize(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()) + + context.get_treatment("exp_test_ab") + context.close() + + self.assertTrue(context.is_closed()) + + self.assertEqual(0, context.get_treatment("exp_test_ab")) + self.assertEqual(0, context.peek_treatment("exp_test_ab")) + self.assertEqual(17, context.get_variable_value("banner.size", 17)) + self.assertEqual(17, context.peek_variable_value("banner.size", 17)) + self.assertEqual({}, context.get_variable_keys()) + self.assertEqual([], context.get_experiments()) + self.assertIsNone(context.get_custom_field_value("exp_test_abc", "country")) + self.assertIsNone(context.get_custom_field_value_type("exp_test_abc", "country")) + self.assertEqual([], context.get_custom_field_keys()) From d99fd8ae94ffe81ca1b6ae3e3ebe0659d490cff9 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 17 Mar 2026 08:47:09 +0000 Subject: [PATCH 16/21] test: update canonical tests to match Phase 1.0 behavior changes - variable_keys now returns dict[str, list[str]] instead of dict[str, str] - get_treatment/peek_treatment/get_variable_value return defaults when not ready instead of raising --- test/test_context_canonical.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/test/test_context_canonical.py b/test/test_context_canonical.py index d0248f0..cc7cf91 100644 --- a/test/test_context_canonical.py +++ b/test/test_context_canonical.py @@ -484,13 +484,13 @@ def test_variable_keys_returns_all_active_keys(self): 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", + "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() @@ -929,8 +929,8 @@ def test_throws_when_not_ready(self): 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") + result = context.get_treatment("exp_test_ab") + self.assertEqual(0, result) context.close() def test_peek_throws_when_not_ready(self): @@ -939,8 +939,8 @@ def test_peek_throws_when_not_ready(self): 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") + result = context.peek_treatment("exp_test_ab") + self.assertEqual(0, result) context.close() def test_variable_value_throws_when_not_ready(self): @@ -949,8 +949,8 @@ def test_variable_value_throws_when_not_ready(self): 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") + result = context.get_variable_value("banner.size", "default") + self.assertEqual("default", result) context.close() From b6cffa5a832230667794b22ecfb1d6b41f1b62f6 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 17 Mar 2026 13:45:16 +0000 Subject: [PATCH 17/21] =?UTF-8?q?feat:=20cross-SDK=20consistency=20fixes?= =?UTF-8?q?=20=E2=80=94=20all=20201=20scenarios=20passing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sdk/absmartly.py | 10 +++++- sdk/absmartly_config.py | 10 +++++- sdk/context_config.py | 12 +++---- sdk/default_audience_deserializer.py | 5 +-- sdk/default_variable_parser.py | 26 --------------- sdk/internal/murmur32.py | 8 ++--- sdk/jsonexpr/operators/match_operator.py | 36 +++++++-------------- test/jsonexpr/operators/test_match.py | 21 ++++++++++++ test/test_default_audience_deserializer.py | 16 ++++++++++ test/test_murmur32.py | 6 ++++ test/test_sdk.py | 37 ++++++++++++++++++++++ 11 files changed, 123 insertions(+), 64 deletions(-) diff --git a/sdk/absmartly.py b/sdk/absmartly.py index 03c6d9a..cf8d24d 100644 --- a/sdk/absmartly.py +++ b/sdk/absmartly.py @@ -116,4 +116,12 @@ def create_context_with(self, AudienceMatcher(self.audience_deserializer)) -ABSmartly = ABsmartly +class ABSmartly(ABsmartly): + def __init__(self, *args, **kwargs): + import warnings + warnings.warn( + "ABSmartly is deprecated, use ABsmartly instead", + DeprecationWarning, + stacklevel=2, + ) + super().__init__(*args, **kwargs) diff --git a/sdk/absmartly_config.py b/sdk/absmartly_config.py index 3a9c140..e1faf48 100644 --- a/sdk/absmartly_config.py +++ b/sdk/absmartly_config.py @@ -17,4 +17,12 @@ class ABsmartlyConfig: variable_parser: Optional[VariableParser] = None -ABSmartlyConfig = ABsmartlyConfig +class ABSmartlyConfig(ABsmartlyConfig): + def __init__(self, *args, **kwargs): + import warnings + warnings.warn( + "ABSmartlyConfig is deprecated, use ABsmartlyConfig instead", + DeprecationWarning, + stacklevel=2, + ) + super().__init__(*args, **kwargs) diff --git a/sdk/context_config.py b/sdk/context_config.py index b5f32cd..fccf949 100644 --- a/sdk/context_config.py +++ b/sdk/context_config.py @@ -1,17 +1,17 @@ import warnings -from typing import Optional +from typing import Dict, Optional from sdk.context_event_logger import ContextEventLogger class ContextConfig: refresh_interval: int = 50 - publish_delay: int = 50 # seconds + publish_delay: int = 50 event_logger: Optional[ContextEventLogger] = None - custom_assignments: {} = None - overrides: {} = None - attributes: {} = None - units: {} = None + custom_assignments: Optional[Dict] = None + overrides: Optional[Dict] = None + attributes: Optional[Dict] = None + units: Optional[Dict] = None historic: bool = False @property diff --git a/sdk/default_audience_deserializer.py b/sdk/default_audience_deserializer.py index 9f7fa82..a6479aa 100644 --- a/sdk/default_audience_deserializer.py +++ b/sdk/default_audience_deserializer.py @@ -15,9 +15,10 @@ 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'): + segment = bytes_[offset:offset + length] + if segment == b'null': return None - return jsons.loadb(bytes_, dict) + return jsons.loadb(segment, dict) except Exception as e: logger.error(f"Failed to deserialize audience filter: {e}") return None diff --git a/sdk/default_variable_parser.py b/sdk/default_variable_parser.py index f2a923a..3429fd0 100644 --- a/sdk/default_variable_parser.py +++ b/sdk/default_variable_parser.py @@ -1,15 +1,9 @@ from typing import Optional import json -import logging - -import jsons -from jsons import DeserializationError from sdk.context import Context from sdk.variable_parser import VariableParser -logger = logging.getLogger(__name__) - class DefaultVariableParser(VariableParser): @@ -20,26 +14,6 @@ def parse(self, config: str) -> Optional[dict]: try: result = json.loads(config) - if isinstance(result, dict): - return result return result 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/murmur32.py b/sdk/internal/murmur32.py index 8a3187e..65f7edd 100644 --- a/sdk/internal/murmur32.py +++ b/sdk/internal/murmur32.py @@ -23,11 +23,11 @@ def digest(key, seed): key[block_start + 0] k1 = (c1 * k1) & 0xFFFFFFFF - k1 = rotate_right(k1, 15) + k1 = rotate_left(k1, 15) k1 = (c2 * k1) & 0xFFFFFFFF h1 ^= k1 - h1 = rotate_right(h1, 13) + h1 = rotate_left(h1, 13) h1 = (h1 * 5 + 0xe6546b64) & 0xFFFFFFFF tail_index = nblocks * 4 @@ -43,7 +43,7 @@ def digest(key, seed): if tail_size > 0: k1 = (k1 * c1) & 0xFFFFFFFF - k1 = rotate_right(k1, 15) + k1 = rotate_left(k1, 15) k1 = (k1 * c2) & 0xFFFFFFFF h1 ^= k1 @@ -63,7 +63,7 @@ def fmix(h: int): return h -def rotate_right(n, d): +def rotate_left(n, d): return (n << d) | (n >> (32 - d)) & 0xFFFFFFFF diff --git a/sdk/jsonexpr/operators/match_operator.py b/sdk/jsonexpr/operators/match_operator.py index e7612e6..6dda2ab 100644 --- a/sdk/jsonexpr/operators/match_operator.py +++ b/sdk/jsonexpr/operators/match_operator.py @@ -1,6 +1,5 @@ import re -import signal -from contextlib import contextmanager +from concurrent.futures import ThreadPoolExecutor, TimeoutError import logging from sdk.jsonexpr.evaluator import Evaluator @@ -9,23 +8,6 @@ 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 @@ -41,14 +23,20 @@ def binary(self, evaluator: Evaluator, lhs: object, rhs: object): try: compiled = re.compile(pattern) - with timeout(self.REGEX_TIMEOUT_SECONDS): - return bool(compiled.match(text)) + with ThreadPoolExecutor(max_workers=1) as pool: + future = pool.submit(compiled.match, text) + try: + result = future.result( + timeout=self.REGEX_TIMEOUT_SECONDS + ) + return bool(result) + except TimeoutError: + logger.warning("Regex execution timeout (potential ReDoS)") + future.cancel() + return None 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 diff --git a/test/jsonexpr/operators/test_match.py b/test/jsonexpr/operators/test_match.py index d600121..6cb1078 100644 --- a/test/jsonexpr/operators/test_match.py +++ b/test/jsonexpr/operators/test_match.py @@ -14,3 +14,24 @@ def test_match(self): self.evaluator, ",l5abcdefghijk", "ijk$")) self.assertTrue(self.operator.binary( self.evaluator, "abcdefghijk", "abc")) + + def test_match_returns_none_for_invalid_pattern(self): + result = self.operator.binary(self.evaluator, "test", "[invalid") + self.assertIsNone(result) + + def test_match_returns_none_for_pattern_too_long(self): + long_pattern = "a" * 1001 + result = self.operator.binary(self.evaluator, "test", long_pattern) + self.assertIsNone(result) + + def test_match_returns_none_for_none_text(self): + result = self.operator.binary(self.evaluator, None, "abc") + self.assertIsNone(result) + + def test_match_returns_none_for_none_pattern(self): + result = self.operator.binary(self.evaluator, "abc", None) + self.assertIsNone(result) + + def test_match_uses_thread_pool_not_sigalrm(self): + result = self.operator.binary(self.evaluator, "hello world", "hello") + self.assertTrue(result) diff --git a/test/test_default_audience_deserializer.py b/test/test_default_audience_deserializer.py index a05b6ce..52d93f6 100644 --- a/test/test_default_audience_deserializer.py +++ b/test/test_default_audience_deserializer.py @@ -28,3 +28,19 @@ def test_deserializer_incorrect(self): 0, len(audience)) self.assertEqual(None, actual) + + def test_deserialize_uses_offset_and_length(self): + deser = DefaultAudienceDeserializer() + prefix = b"JUNK" + payload = b'{"key": "value"}' + suffix = b"MOREJUNK" + full = prefix + payload + suffix + actual = deser.deserialize(full, len(prefix), + len(payload)) + self.assertEqual({"key": "value"}, actual) + + def test_deserialize_null_with_offset(self): + deser = DefaultAudienceDeserializer() + data = b"XXXXnullYYYY" + actual = deser.deserialize(data, 4, 4) + self.assertIsNone(actual) diff --git a/test/test_murmur32.py b/test/test_murmur32.py index e4de026..03811b8 100644 --- a/test/test_murmur32.py +++ b/test/test_murmur32.py @@ -123,3 +123,9 @@ def test_seed1_quick_brown_fox(self): self._assert_hash( "The quick brown fox jumps over the lazy dog", 0x00000001, 0x78e69e27) + + def test_rotate_left_renamed(self): + self.assertTrue(hasattr(murmur, 'rotate_left')) + self.assertFalse(hasattr(murmur, 'rotate_right')) + result = murmur.rotate_left(1, 1) + self.assertEqual(result, 2) diff --git a/test/test_sdk.py b/test/test_sdk.py index 194a0e8..2b46206 100644 --- a/test/test_sdk.py +++ b/test/test_sdk.py @@ -157,6 +157,43 @@ def test_sdk_close(self): self.assertTrue(context.is_closed()) + def test_absmartly_deprecation_warning(self): + import warnings + from sdk.absmartly import ABsmartly + mock_data_provider = Mock(spec=ContextDataProvider) + mock_event_handler = Mock(spec=ContextEventHandler) + + config_lowercase = ABSmartlyConfig.__bases__[0]() + config_lowercase.context_data_provider = mock_data_provider + config_lowercase.context_event_handler = mock_event_handler + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + config = ABSmartlyConfig() + self.assertTrue(any( + issubclass(warning.category, DeprecationWarning) and + "ABSmartlyConfig" in str(warning.message) + for warning in w + )) + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + config.context_data_provider = mock_data_provider + config.context_event_handler = mock_event_handler + sdk = ABSmartly(config) + self.assertTrue(any( + issubclass(warning.category, DeprecationWarning) and + "ABSmartly" in str(warning.message) + for warning in w + )) + + def test_context_config_type_annotations(self): + config = ContextConfig() + self.assertIsNone(config.custom_assignments) + self.assertIsNone(config.overrides) + self.assertIsNone(config.attributes) + self.assertIsNone(config.units) + if __name__ == '__main__': unittest.main() From aa0c329e03e12f191c797a04c34620b72738c274 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Wed, 18 Mar 2026 12:08:11 +0000 Subject: [PATCH 18/21] refactor: rename ContextEventHandler to ContextPublisher --- sdk/absmartly.py | 4 ++-- sdk/absmartly_config.py | 4 ++-- sdk/context.py | 4 ++-- sdk/context_event_handler.py | 19 ++++++++----------- sdk/context_publisher.py | 14 ++++++++++++++ sdk/default_context_event_handler.py | 23 ++++++++++------------- sdk/default_context_publisher.py | 19 +++++++++++++++++++ 7 files changed, 57 insertions(+), 30 deletions(-) create mode 100644 sdk/context_publisher.py create mode 100644 sdk/default_context_publisher.py diff --git a/sdk/absmartly.py b/sdk/absmartly.py index cf8d24d..114b84e 100644 --- a/sdk/absmartly.py +++ b/sdk/absmartly.py @@ -10,7 +10,7 @@ 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_context_publisher import DefaultContextPublisher from sdk.default_http_client import DefaultHTTPClient from sdk.default_http_client_config import DefaultHTTPClientConfig from sdk.default_variable_parser import DefaultVariableParser @@ -81,7 +81,7 @@ def __init__(self, config: ABsmartlyConfig): if self.context_event_handler is None: self.context_event_handler = \ - DefaultContextEventHandler(self.client) + DefaultContextPublisher(self.client) if self.variable_parser is None: self.variable_parser = DefaultVariableParser() diff --git a/sdk/absmartly_config.py b/sdk/absmartly_config.py index e1faf48..98676b5 100644 --- a/sdk/absmartly_config.py +++ b/sdk/absmartly_config.py @@ -3,14 +3,14 @@ from sdk.audience_deserializer import AudienceDeserializer from sdk.client import Client from sdk.context_data_provider import ContextDataProvider -from sdk.context_event_handler import ContextEventHandler +from sdk.context_publisher import ContextPublisher from sdk.context_event_logger import ContextEventLogger from sdk.variable_parser import VariableParser class ABsmartlyConfig: context_data_provider: Optional[ContextDataProvider] = None - context_event_handler: Optional[ContextEventHandler] = None + context_event_handler: Optional[ContextPublisher] = None context_event_logger: Optional[ContextEventLogger] = None audience_deserializer: Optional[AudienceDeserializer] = None client: Optional[Client] = None diff --git a/sdk/context.py b/sdk/context.py index 9c1a1e9..f72b7ed 100644 --- a/sdk/context.py +++ b/sdk/context.py @@ -9,7 +9,7 @@ from sdk.audience_matcher import AudienceMatcher from sdk.context_config import ContextConfig from sdk.context_data_provider import ContextDataProvider -from sdk.context_event_handler import ContextEventHandler +from sdk.context_publisher import ContextPublisher from sdk.context_event_logger import ContextEventLogger, EventType from sdk.internal.lock.atomic_bool import AtomicBool from sdk.internal.lock.atomic_int import AtomicInt @@ -69,7 +69,7 @@ class Context: def __init__(self, clock: Clock, config: ContextConfig, data_future: Future, data_provider: ContextDataProvider, - event_handler: ContextEventHandler, + event_handler: ContextPublisher, event_logger: ContextEventLogger, variable_parser: VariableParser, audience_matcher: AudienceMatcher): diff --git a/sdk/context_event_handler.py b/sdk/context_event_handler.py index 9003f5b..4f56d67 100644 --- a/sdk/context_event_handler.py +++ b/sdk/context_event_handler.py @@ -1,14 +1,11 @@ -from abc import abstractmethod -from concurrent.futures import Future -from typing import Optional +import warnings -from sdk.json.context_data import ContextData -from sdk.json.publish_event import PublishEvent +from sdk.context_publisher import ContextPublisher +warnings.warn( + "ContextEventHandler is deprecated, use ContextPublisher instead.", + DeprecationWarning, + stacklevel=2 +) -class ContextEventHandler: - - @abstractmethod - def publish(self, context, event: PublishEvent) -> \ - Future[Optional[ContextData]]: - raise NotImplementedError +ContextEventHandler = ContextPublisher diff --git a/sdk/context_publisher.py b/sdk/context_publisher.py new file mode 100644 index 0000000..d78d84a --- /dev/null +++ b/sdk/context_publisher.py @@ -0,0 +1,14 @@ +from abc import abstractmethod +from concurrent.futures import Future +from typing import Optional + +from sdk.json.context_data import ContextData +from sdk.json.publish_event import PublishEvent + + +class ContextPublisher: + + @abstractmethod + def publish(self, context, event: PublishEvent) -> \ + Future[Optional[ContextData]]: + raise NotImplementedError diff --git a/sdk/default_context_event_handler.py b/sdk/default_context_event_handler.py index 6b1fc8b..5b18099 100644 --- a/sdk/default_context_event_handler.py +++ b/sdk/default_context_event_handler.py @@ -1,19 +1,16 @@ -from concurrent.futures import Future -from typing import Optional +import warnings from sdk.client import Client -from sdk.context import Context -from sdk.context_event_handler import ContextEventHandler -from sdk.json.context_data import ContextData -from sdk.json.publish_event import PublishEvent +from sdk.default_context_publisher import DefaultContextPublisher -class DefaultContextEventHandler(ContextEventHandler): +class DefaultContextEventHandler(DefaultContextPublisher): + """Deprecated: Use DefaultContextPublisher instead.""" def __init__(self, client: Client): - self.client = client - - def publish(self, - context: Context, - event: PublishEvent) -> Future[Optional[ContextData]]: - return self.client.publish(event) + warnings.warn( + "DefaultContextEventHandler is deprecated, use DefaultContextPublisher instead.", + DeprecationWarning, + stacklevel=2 + ) + super().__init__(client) diff --git a/sdk/default_context_publisher.py b/sdk/default_context_publisher.py new file mode 100644 index 0000000..bdf7e4f --- /dev/null +++ b/sdk/default_context_publisher.py @@ -0,0 +1,19 @@ +from concurrent.futures import Future +from typing import Optional + +from sdk.client import Client +from sdk.context import Context +from sdk.context_publisher import ContextPublisher +from sdk.json.context_data import ContextData +from sdk.json.publish_event import PublishEvent + + +class DefaultContextPublisher(ContextPublisher): + + def __init__(self, client: Client): + self.client = client + + def publish(self, + context: Context, + event: PublishEvent) -> Future[Optional[ContextData]]: + return self.client.publish(event) From f324fb3e7eb632161b4829be39bbe4b4778d1400 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Wed, 18 Mar 2026 13:52:48 +0000 Subject: [PATCH 19/21] fix: address coderabbit review issues - Fix rotate_left operator precedence bug in murmur32 (critical hash corruption fix) - Make ContextPublisher inherit from ABC to enforce abstract method contract - Fix return type annotations from Optional[dict] to Optional[Any] in variable parsers - Add defensive copies in get_attribute/get_attributes to prevent external mutation - Re-raise exceptions from close() so callers can detect publish failures - Fix f-string without placeholders and use logger.exception in match_operator - Add noqa: S324 suppression for non-cryptographic MD5 usage in test helper - Strengthen test_concurrent_publish with behavioral assertions - Add custom_assignments path to create_ready_context test helper - Fix test_sdk unused variable and update tests to handle close() re-raise --- sdk/context.py | 6 ++++-- sdk/context_publisher.py | 4 ++-- sdk/default_variable_parser.py | 4 ++-- sdk/internal/murmur32.py | 2 +- sdk/jsonexpr/operators/match_operator.py | 2 +- sdk/variable_parser.py | 4 ++-- test/test_concurrency.py | 2 ++ test/test_context.py | 6 ++++-- test/test_context_canonical.py | 4 ++++ test/test_sdk.py | 2 +- test/test_variant_assigner.py | 2 +- 11 files changed, 24 insertions(+), 14 deletions(-) diff --git a/sdk/context.py b/sdk/context.py index f72b7ed..828a1d6 100644 --- a/sdk/context.py +++ b/sdk/context.py @@ -216,7 +216,7 @@ def get_attribute(self, name: str): for attr in self.attributes: if attr.name == name: result = attr.value - return result + return copy.deepcopy(result) if isinstance(result, (dict, list)) else result finally: self.context_lock.release_read() @@ -225,7 +225,8 @@ def get_attributes(self): self.context_lock.acquire_read() result = {} for attr in self.attributes: - result[attr.name] = attr.value + value = attr.value + result[attr.name] = copy.deepcopy(value) if isinstance(value, (dict, list)) else value return result finally: self.context_lock.release_read() @@ -548,6 +549,7 @@ def close(self): except Exception as e: self.close_error = e self.log_error(e) + raise def finalize(self): return self.close() diff --git a/sdk/context_publisher.py b/sdk/context_publisher.py index d78d84a..88ee2cf 100644 --- a/sdk/context_publisher.py +++ b/sdk/context_publisher.py @@ -1,4 +1,4 @@ -from abc import abstractmethod +from abc import ABC, abstractmethod from concurrent.futures import Future from typing import Optional @@ -6,7 +6,7 @@ from sdk.json.publish_event import PublishEvent -class ContextPublisher: +class ContextPublisher(ABC): @abstractmethod def publish(self, context, event: PublishEvent) -> \ diff --git a/sdk/default_variable_parser.py b/sdk/default_variable_parser.py index 3429fd0..f523452 100644 --- a/sdk/default_variable_parser.py +++ b/sdk/default_variable_parser.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Any, Optional import json from sdk.context import Context @@ -11,7 +11,7 @@ def parse(self, context: Context, experiment_name: str, variant_name: str, - config: str) -> Optional[dict]: + config: str) -> Optional[Any]: try: result = json.loads(config) return result diff --git a/sdk/internal/murmur32.py b/sdk/internal/murmur32.py index 65f7edd..fa84fb2 100644 --- a/sdk/internal/murmur32.py +++ b/sdk/internal/murmur32.py @@ -64,7 +64,7 @@ def fmix(h: int): def rotate_left(n, d): - return (n << d) | (n >> (32 - d)) & 0xFFFFFFFF + return ((n << d) | (n >> (32 - d))) & 0xFFFFFFFF def to_signed32(n): diff --git a/sdk/jsonexpr/operators/match_operator.py b/sdk/jsonexpr/operators/match_operator.py index 6dda2ab..09f33b5 100644 --- a/sdk/jsonexpr/operators/match_operator.py +++ b/sdk/jsonexpr/operators/match_operator.py @@ -38,6 +38,6 @@ def binary(self, evaluator: Evaluator, lhs: object, rhs: object): logger.warning(f"Invalid regex pattern: {e}") return None except Exception as e: - logger.error(f"Unexpected error in regex matching: {e}") + logger.exception("Unexpected error in regex matching: %s", e) return None return None diff --git a/sdk/variable_parser.py b/sdk/variable_parser.py index cf23979..7e908ea 100644 --- a/sdk/variable_parser.py +++ b/sdk/variable_parser.py @@ -1,5 +1,5 @@ from abc import abstractmethod -from typing import Optional +from typing import Any, Optional class VariableParser: @@ -9,5 +9,5 @@ def parse(self, context, experiment_name: str, variant_name: str, - variable_value: str) -> Optional[dict]: + variable_value: str) -> Optional[Any]: raise NotImplementedError diff --git a/test/test_concurrency.py b/test/test_concurrency.py index e869587..4fac526 100644 --- a/test/test_concurrency.py +++ b/test/test_concurrency.py @@ -189,6 +189,8 @@ def call_publish(): t.join() self.assertEqual(0, len(errors)) + self.assertGreater(publish_count[0], 0) + self.assertEqual(0, context.get_pending_count()) context.close() diff --git a/test/test_context.py b/test/test_context.py index ac81bf2..063ca32 100644 --- a/test/test_context.py +++ b/test/test_context.py @@ -1539,6 +1539,7 @@ def capturing_publish(event): self.assertEqual(1, len(published_events[0].goals)) self.assertEqual("goal1", published_events[0].goals[0].name) self.assertEqual(1, context.get_pending_count()) + self.client.publish = original_publish context.close() def test_flush_restores_events_on_failure(self): @@ -1585,11 +1586,12 @@ def failing_publish(event): return future self.client.publish = failing_publish - context.close() + with self.assertRaises(RuntimeError) as cm: + context.close() self.assertIsNotNone(context.close_error) self.assertIsInstance(context.close_error, RuntimeError) - self.assertEqual("Publish failed", str(context.close_error)) + self.assertEqual("Publish failed", str(cm.exception)) def test_close_no_error_on_success(self): self.set_up() diff --git a/test/test_context_canonical.py b/test/test_context_canonical.py index cc7cf91..0da9082 100644 --- a/test/test_context_canonical.py +++ b/test/test_context_canonical.py @@ -159,6 +159,8 @@ def create_ready_context(self, **kwargs): config.units = kwargs.get('units', self.units) if 'overrides' in kwargs: config.overrides = kwargs['overrides'] + if 'custom_assignments' in kwargs: + config.custom_assignments = kwargs['custom_assignments'] if 'cassignments' in kwargs: config.cassigmnents = kwargs['cassignments'] data_future = kwargs.get('data_future', self.data_future_ready) @@ -222,6 +224,7 @@ def test_event_logger_on_publish_error(self): except RuntimeError: pass self.assertEqual(self.event_logger.last_type, EventType.ERROR) + self.client._publish_future = None context.close() def test_event_logger_on_refresh_success(self): @@ -624,6 +627,7 @@ def test_publish_propagates_client_error(self): self.client._publish_future = fail_future with self.assertRaises(RuntimeError): context.publish() + self.client._publish_future = None context.close() def test_publish_throws_after_close(self): diff --git a/test/test_sdk.py b/test/test_sdk.py index 2b46206..7ca26c4 100644 --- a/test/test_sdk.py +++ b/test/test_sdk.py @@ -59,7 +59,7 @@ def test_sdk_create_missing_api_key(self): client_config.environment = "dev" http_client = DefaultHTTPClient(DefaultHTTPClientConfig()) - client = Client(client_config, http_client) + _ = Client(client_config, http_client) self.assertIsNone(client_config.api_key) diff --git a/test/test_variant_assigner.py b/test/test_variant_assigner.py index 943ed2c..0fac4a3 100644 --- a/test/test_variant_assigner.py +++ b/test/test_variant_assigner.py @@ -6,7 +6,7 @@ def hash_unit(unit): - dig = hashlib.md5(str(unit).encode('utf-8')).digest() + dig = hashlib.md5(str(unit).encode('utf-8')).digest() # noqa: S324 return base64.urlsafe_b64encode(dig).rstrip(b'=') From 54ffc14cb43f217596f43bc0631ad5ecedfbe2f0 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Wed, 18 Mar 2026 18:10:29 +0000 Subject: [PATCH 20/21] fix: reset mock to success before close in test_recovery_from_failed_publish --- test/test_context.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/test_context.py b/test/test_context.py index 063ca32..701d84a 100644 --- a/test/test_context.py +++ b/test/test_context.py @@ -1430,6 +1430,12 @@ def failing_publish(event): self.assertFalse(context.is_closed()) self.assertFalse(context.is_failed()) + + def success_publish(event): + future = Future() + future.set_result(None) + return future + self.client.publish = success_publish context.close() def test_recovery_from_failed_refresh(self): From 2a4b8fb051c70700b869eb9f1c84f23eea9de8ee Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Wed, 18 Mar 2026 18:59:37 +0000 Subject: [PATCH 21/21] feat: add context_publisher setter and deprecate context_event_handler Add context_publisher as the primary field in ABsmartlyConfig, replacing context_event_handler. The old name is kept as a deprecated property that delegates to context_publisher for backward compatibility. Export ContextPublisher from the public SDK API. --- sdk/__init__.py | 2 ++ sdk/absmartly.py | 34 ++++++++++++++++++++++++++++------ sdk/absmartly_config.py | 22 ++++++++++++++++++++-- test/test_named_param_init.py | 2 +- 4 files changed, 51 insertions(+), 9 deletions(-) diff --git a/sdk/__init__.py b/sdk/__init__.py index 8815a65..135d078 100644 --- a/sdk/__init__.py +++ b/sdk/__init__.py @@ -5,6 +5,7 @@ from sdk.context import Context from sdk.context_config import ContextConfig from sdk.context_event_logger import ContextEventLogger +from sdk.context_publisher import ContextPublisher from sdk.default_http_client import DefaultHTTPClient from sdk.default_http_client_config import DefaultHTTPClientConfig @@ -18,6 +19,7 @@ "Context", "ContextConfig", "ContextEventLogger", + "ContextPublisher", "DefaultHTTPClient", "DefaultHTTPClientConfig", ] diff --git a/sdk/absmartly.py b/sdk/absmartly.py index 114b84e..cab1d20 100644 --- a/sdk/absmartly.py +++ b/sdk/absmartly.py @@ -1,3 +1,4 @@ +import warnings from concurrent.futures import Future from typing import Optional @@ -66,21 +67,21 @@ def create( def __init__(self, config: ABsmartlyConfig): self.context_data_provider = config.context_data_provider - self.context_event_handler = config.context_event_handler + self.context_publisher = config.context_publisher self.context_event_logger = config.context_event_logger self.variable_parser = config.variable_parser self.audience_deserializer = config.audience_deserializer if self.context_data_provider is None or \ - self.context_event_handler is None: + self.context_publisher is None: self.client = config.client if self.context_data_provider is None: self.context_data_provider = \ DefaultContextDataProvider(self.client) - if self.context_event_handler is None: - self.context_event_handler = \ + if self.context_publisher is None: + self.context_publisher = \ DefaultContextPublisher(self.client) if self.variable_parser is None: @@ -89,6 +90,27 @@ def __init__(self, config: ABsmartlyConfig): if self.audience_deserializer is None: self.audience_deserializer = DefaultAudienceDeserializer() + def __getattr__(self, name): + if name == "context_event_handler": + warnings.warn( + "context_event_handler is deprecated, use context_publisher instead", + DeprecationWarning, + stacklevel=2, + ) + return self.context_publisher + raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'") + + def __setattr__(self, name, value): + if name == "context_event_handler": + warnings.warn( + "context_event_handler is deprecated, use context_publisher instead", + DeprecationWarning, + stacklevel=2, + ) + super().__setattr__("context_publisher", value) + else: + super().__setattr__(name, value) + def get_context_data(self) -> Future[Optional[ContextData]]: return self.context_data_provider.get_context_data() @@ -97,7 +119,7 @@ def create_context(self, config: ContextConfig) -> Context: config, self.context_data_provider.get_context_data(), self.context_data_provider, - self.context_event_handler, + self.context_publisher, self.context_event_logger, self.variable_parser, AudienceMatcher(self.audience_deserializer)) @@ -110,7 +132,7 @@ def create_context_with(self, return Context(SystemClockUTC(), config, future_data, self.context_data_provider, - self.context_event_handler, + self.context_publisher, self.context_event_logger, self.variable_parser, AudienceMatcher(self.audience_deserializer)) diff --git a/sdk/absmartly_config.py b/sdk/absmartly_config.py index 98676b5..f767084 100644 --- a/sdk/absmartly_config.py +++ b/sdk/absmartly_config.py @@ -1,3 +1,4 @@ +import warnings from typing import Optional from sdk.audience_deserializer import AudienceDeserializer @@ -10,16 +11,33 @@ class ABsmartlyConfig: context_data_provider: Optional[ContextDataProvider] = None - context_event_handler: Optional[ContextPublisher] = None + context_publisher: Optional[ContextPublisher] = None context_event_logger: Optional[ContextEventLogger] = None audience_deserializer: Optional[AudienceDeserializer] = None client: Optional[Client] = None variable_parser: Optional[VariableParser] = None + @property + def context_event_handler(self) -> Optional[ContextPublisher]: + warnings.warn( + "context_event_handler is deprecated, use context_publisher instead", + DeprecationWarning, + stacklevel=2, + ) + return self.context_publisher + + @context_event_handler.setter + def context_event_handler(self, value: Optional[ContextPublisher]): + warnings.warn( + "context_event_handler is deprecated, use context_publisher instead", + DeprecationWarning, + stacklevel=2, + ) + self.context_publisher = value + class ABSmartlyConfig(ABsmartlyConfig): def __init__(self, *args, **kwargs): - import warnings warnings.warn( "ABSmartlyConfig is deprecated, use ABsmartlyConfig instead", DeprecationWarning, diff --git a/test/test_named_param_init.py b/test/test_named_param_init.py index 34acc92..b4b0337 100644 --- a/test/test_named_param_init.py +++ b/test/test_named_param_init.py @@ -19,7 +19,7 @@ def test_create_with_all_required_params(self): self.assertIsNotNone(sdk) self.assertIsNotNone(sdk.context_data_provider) - self.assertIsNotNone(sdk.context_event_handler) + self.assertIsNotNone(sdk.context_publisher) self.assertIsNotNone(sdk.variable_parser) self.assertIsNotNone(sdk.audience_deserializer)