From 7fd936a90e61b041bcf377cc0d041c1acb768af0 Mon Sep 17 00:00:00 2001 From: Usman Abbas Date: Thu, 26 Mar 2026 19:24:37 +0500 Subject: [PATCH] Use:feat(python-sdk): add core, context, and public sdk surface --- src/convertcom_sdk/__init__.py | 12 +- src/convertcom_sdk/config.py | 25 ++ src/convertcom_sdk/context.py | 296 ++++++++++++++++++ src/convertcom_sdk/core.py | 117 +++++++ src/convertcom_sdk/data/data_manager.py | 89 ++++++ src/convertcom_sdk/data/data_store_manager.py | 3 + src/convertcom_sdk/enums.py | 10 + src/convertcom_sdk/errors.py | 4 + src/convertcom_sdk/events/event_manager.py | 24 +- src/convertcom_sdk/sdk.py | 62 ++++ tests/unit/test_context_public_api.py | 89 ++++++ tests/unit/test_core.py | 46 +++ 12 files changed, 767 insertions(+), 10 deletions(-) create mode 100644 src/convertcom_sdk/config.py create mode 100644 src/convertcom_sdk/context.py create mode 100644 src/convertcom_sdk/core.py create mode 100644 src/convertcom_sdk/sdk.py create mode 100644 tests/unit/test_context_public_api.py create mode 100644 tests/unit/test_core.py diff --git a/src/convertcom_sdk/__init__.py b/src/convertcom_sdk/__init__.py index 4ee7851..e903e1b 100644 --- a/src/convertcom_sdk/__init__.py +++ b/src/convertcom_sdk/__init__.py @@ -1,4 +1,7 @@ from .api.api_manager import ApiManager +from .config import DEFAULT_CONFIG, build_config +from .context import Context +from .core import Core from .data.data_manager import DataManager from .data.data_store_manager import DataStoreManager from .events.event_manager import EventManager @@ -6,16 +9,23 @@ from .features.feature_manager import FeatureManager from .segments.segments_manager import SegmentsManager from .bucketing.bucketing_manager import BucketingAllocation, BucketingManager -from .enums import BucketingError, FeatureStatus, RuleError, SystemEvents +from .enums import BucketingError, EntityType, FeatureStatus, RuleError, SystemEvents from .rules.rule_manager import RuleManager +from .sdk import ConvertSDK __all__ = [ "ApiManager", "BucketingAllocation", "BucketingError", "BucketingManager", + "build_config", + "Context", + "ConvertSDK", + "Core", "DataManager", "DataStoreManager", + "DEFAULT_CONFIG", + "EntityType", "EventManager", "ExperienceManager", "FeatureManager", diff --git a/src/convertcom_sdk/config.py b/src/convertcom_sdk/config.py new file mode 100644 index 0000000..7f50d11 --- /dev/null +++ b/src/convertcom_sdk/config.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from convertcom_sdk.utils.object_utils import object_deep_merge + +DEFAULT_CONFIG: dict[str, Any] = { + "api": {"endpoint": {"config": "", "track": ""}}, + "environment": "staging", + "bucketing": {"max_traffic": 10000, "hash_seed": 9999}, + "data": {}, + "dataStore": None, + "dataRefreshInterval": 300000, + "events": {"batch_size": 10, "release_interval": 1000}, + "logger": {"logLevel": "debug", "customLoggers": []}, + "rules": {"keys_case_sensitive": True, "comparisonProcessor": None}, + "network": {"tracking": True, "cacheLevel": "default", "source": "python-sdk"}, + "sdkKey": "", + "sdkKeySecret": "", +} + + +def build_config(config: Mapping[str, Any] | None = None) -> dict[str, Any]: + return object_deep_merge(DEFAULT_CONFIG, dict(config or {})) diff --git a/src/convertcom_sdk/context.py b/src/convertcom_sdk/context.py new file mode 100644 index 0000000..021383e --- /dev/null +++ b/src/convertcom_sdk/context.py @@ -0,0 +1,296 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from convertcom_sdk.enums import EntityType, SystemEvents +from convertcom_sdk.utils.object_utils import object_deep_merge, object_not_empty + +ENTITY_TYPE_MAP = { + EntityType.AUDIENCE.value: "audiences", + EntityType.LOCATION.value: "locations", + EntityType.SEGMENT.value: "segments", + EntityType.FEATURE.value: "features", + EntityType.GOAL.value: "goals", + EntityType.EXPERIENCE.value: "experiences", +} + + +def _normalize_entity_type(entity_type: EntityType | str) -> EntityType: + if isinstance(entity_type, EntityType): + return entity_type + return EntityType(entity_type) + + +class Context: + def __init__( + self, + config: Mapping[str, Any] | None, + visitor_id: str | None, + *, + event_manager: Any, + experience_manager: Any, + feature_manager: Any, + segments_manager: Any, + data_manager: Any, + api_manager: Any, + visitor_properties: Mapping[str, Any] | None = None, + ) -> None: + self._config = dict(config or {}) + self._visitor_id = visitor_id + self._event_manager = event_manager + self._experience_manager = experience_manager + self._feature_manager = feature_manager + self._segments_manager = segments_manager + self._data_manager = data_manager + self._api_manager = api_manager + self._environment = self._config.get("environment") + self._visitor_properties: dict[str, Any] = {} + if object_not_empty(visitor_properties): + filtered = self._data_manager.filter_report_segments(visitor_properties) + if filtered["properties"]: + self._visitor_properties = dict(filtered["properties"]) + self._segments_manager.put_segments(visitor_id, visitor_properties) + + def _has_visitor(self) -> bool: + return bool(self._visitor_id) + + def _base_attributes( + self, + attributes: Mapping[str, Any] | None = None, + *, + type_casting_default: bool | None = None, + ) -> dict[str, Any]: + attributes = dict(attributes or {}) + result = { + "visitorProperties": self.get_visitor_properties(attributes.get("visitorProperties")), + "locationProperties": attributes.get("locationProperties"), + "updateVisitorProperties": attributes.get("updateVisitorProperties"), + "environment": attributes.get("environment") or self._environment, + } + if type_casting_default is not None: + result["typeCasting"] = attributes.get("typeCasting", type_casting_default) + if "forceVariationId" in attributes: + result["forceVariationId"] = attributes.get("forceVariationId") + return result + + def run_experience(self, experience_key: str, attributes: Mapping[str, Any] | None = None) -> Any: + if not self._has_visitor(): + return None + bucketed_variation = self._experience_manager.select_variation( + self._visitor_id, + experience_key, + self._base_attributes(attributes), + ) + if isinstance(bucketed_variation, Mapping): + self._event_manager.fire( + SystemEvents.BUCKETING, + { + "visitorId": self._visitor_id, + "experienceKey": experience_key, + "variationKey": bucketed_variation.get("key"), + }, + None, + True, + ) + return bucketed_variation + + def runExperience(self, experience_key: str, attributes: Mapping[str, Any] | None = None) -> Any: + return self.run_experience(experience_key, attributes) + + def run_experiences(self, attributes: Mapping[str, Any] | None = None) -> list[Any] | None: + if not self._has_visitor(): + return None + bucketed_variations = self._experience_manager.select_variations( + self._visitor_id, + self._base_attributes(attributes), + ) + for bucketed_variation in bucketed_variations: + self._event_manager.fire( + SystemEvents.BUCKETING, + { + "visitorId": self._visitor_id, + "experienceKey": bucketed_variation.get("experienceKey"), + "variationKey": bucketed_variation.get("key"), + }, + None, + True, + ) + return bucketed_variations + + def runExperiences(self, attributes: Mapping[str, Any] | None = None) -> list[Any] | None: + return self.run_experiences(attributes) + + def run_feature(self, key: str, attributes: Mapping[str, Any] | None = None) -> Any: + if not self._has_visitor(): + return None + attributes = dict(attributes or {}) + bucketed_feature = self._feature_manager.run_feature( + self._visitor_id, + key, + self._base_attributes(attributes, type_casting_default=True), + attributes.get("experienceKeys"), + ) + items = bucketed_feature if isinstance(bucketed_feature, list) else [bucketed_feature] + for item in [feature for feature in items if isinstance(feature, Mapping)]: + self._event_manager.fire( + SystemEvents.BUCKETING, + { + "visitorId": self._visitor_id, + "experienceKey": item.get("experienceKey"), + "featureKey": key, + "status": item.get("status"), + }, + None, + True, + ) + return bucketed_feature + + def runFeature(self, key: str, attributes: Mapping[str, Any] | None = None) -> Any: + return self.run_feature(key, attributes) + + def run_features(self, attributes: Mapping[str, Any] | None = None) -> list[Any] | None: + if not self._has_visitor(): + return None + bucketed_features = self._feature_manager.run_features( + self._visitor_id, + self._base_attributes(attributes, type_casting_default=True), + ) + for item in [feature for feature in bucketed_features if isinstance(feature, Mapping)]: + self._event_manager.fire( + SystemEvents.BUCKETING, + { + "visitorId": self._visitor_id, + "experienceKey": item.get("experienceKey"), + "featureKey": item.get("key"), + "status": item.get("status"), + }, + None, + True, + ) + return bucketed_features + + def runFeatures(self, attributes: Mapping[str, Any] | None = None) -> list[Any] | None: + return self.run_features(attributes) + + def track_conversion(self, goal_key: str, attributes: Mapping[str, Any] | None = None) -> Any: + if not self._has_visitor(): + return None + attributes = dict(attributes or {}) + goal_data = attributes.get("conversionData") + if goal_data is not None and not isinstance(goal_data, list): + return None + result = self._data_manager.convert( + self._visitor_id, + goal_key, + attributes.get("ruleData"), + goal_data, + self._segments_manager.get_segments(self._visitor_id), + attributes.get("conversionSetting"), + ) + if result: + self._event_manager.fire( + SystemEvents.CONVERSION, + {"visitorId": self._visitor_id, "goalKey": goal_key}, + None, + True, + ) + return result + + def trackConversion(self, goal_key: str, attributes: Mapping[str, Any] | None = None) -> Any: + return self.track_conversion(goal_key, attributes) + + def set_default_segments(self, segments: Mapping[str, Any]) -> None: + self._segments_manager.put_segments(self._visitor_id, segments) + + def setDefaultSegments(self, segments: Mapping[str, Any]) -> None: + self.set_default_segments(segments) + + def set_custom_segments(self, segment_keys: list[str] | str, attributes: Mapping[str, Any] | None = None) -> Any: + return self.run_custom_segments(segment_keys, attributes) + + def setCustomSegments(self, segment_keys: list[str] | str, attributes: Mapping[str, Any] | None = None) -> Any: + return self.set_custom_segments(segment_keys, attributes) + + def run_custom_segments(self, segment_keys: list[str] | str, attributes: Mapping[str, Any] | None = None) -> Any: + if not self._has_visitor(): + return None + keys = [segment_keys] if isinstance(segment_keys, str) else list(segment_keys) + return self._segments_manager.select_custom_segments( + self._visitor_id, + keys, + self.get_visitor_properties((attributes or {}).get("ruleData")), + ) + + def runCustomSegments(self, segment_keys: list[str] | str, attributes: Mapping[str, Any] | None = None) -> Any: + return self.run_custom_segments(segment_keys, attributes) + + def update_visitor_properties(self, visitor_id: str, visitor_properties: Mapping[str, Any]) -> None: + self._data_manager.put_data(visitor_id, {"segments": dict(visitor_properties)}) + + def updateVisitorProperties(self, visitor_id: str, visitor_properties: Mapping[str, Any]) -> None: + self.update_visitor_properties(visitor_id, visitor_properties) + + def get_config_entity(self, key: str, entity_type: EntityType | str) -> Any: + entity_type = _normalize_entity_type(entity_type) + if entity_type == EntityType.VARIATION: + for experience in self._data_manager.get_entities_list("experiences"): + variation = self._data_manager.get_sub_item( + "experiences", + experience.get("key"), + "variations", + key, + "key", + "key", + ) + if variation: + return variation + return None + return self._data_manager.get_entity(key, ENTITY_TYPE_MAP[entity_type.value]) + + def getConfigEntity(self, key: str, entity_type: EntityType | str) -> Any: + return self.get_config_entity(key, entity_type) + + def get_config_entity_by_id(self, entity_id: str, entity_type: EntityType | str) -> Any: + entity_type = _normalize_entity_type(entity_type) + if entity_type == EntityType.VARIATION: + for experience in self._data_manager.get_entities_list("experiences"): + variation = self._data_manager.get_sub_item( + "experiences", + experience.get("id"), + "variations", + entity_id, + "id", + "id", + ) + if variation: + return variation + return None + return self._data_manager.get_entity_by_id(entity_id, ENTITY_TYPE_MAP[entity_type.value]) + + def getConfigEntityById(self, entity_id: str, entity_type: EntityType | str) -> Any: + return self.get_config_entity_by_id(entity_id, entity_type) + + def get_visitor_data(self) -> dict[str, Any]: + return self._data_manager.get_data(self._visitor_id) or {} + + def getVisitorData(self) -> dict[str, Any]: + return self.get_visitor_data() + + def release_queues(self, reason: str | None = None) -> Any: + if self._data_manager.data_store_manager: + self._data_manager.data_store_manager.release_queue(reason) + return self._api_manager.release_queue(reason) + + def releaseQueues(self, reason: str | None = None) -> Any: + return self.release_queues(reason) + + def get_visitor_properties(self, attributes: Mapping[str, Any] | None = None) -> dict[str, Any]: + store_data = self._data_manager.get_data(self._visitor_id) or {} + segments = store_data.get("segments") or {} + visitor_properties = ( + object_deep_merge(self._visitor_properties, dict(attributes or {})) + if attributes + else dict(self._visitor_properties) + ) + return object_deep_merge(segments, visitor_properties) diff --git a/src/convertcom_sdk/core.py b/src/convertcom_sdk/core.py new file mode 100644 index 0000000..bc6a2d6 --- /dev/null +++ b/src/convertcom_sdk/core.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +from collections.abc import Callable, Mapping +from typing import Any + +from convertcom_sdk.errors import DATA_OBJECT_MISSING, SDK_OR_DATA_OBJECT_REQUIRED +from convertcom_sdk.enums import SystemEvents + + +class Core: + def __init__( + self, + config: Mapping[str, Any] | None, + *, + data_manager: Any, + event_manager: Any, + experience_manager: Any, + feature_manager: Any, + segments_manager: Any, + api_manager: Any, + ) -> None: + self.data: dict[str, Any] | None = None + self._initialized = False + self._config = dict(config or {}) + self._data_manager = data_manager + self._event_manager = event_manager + self._experience_manager = experience_manager + self._feature_manager = feature_manager + self._segments_manager = segments_manager + self._api_manager = api_manager + self.initialize(self._config) + + def initialize(self, config: Mapping[str, Any] | None) -> None: + if not config: + return + self._config = dict(config) + sdk_key = self._config.get("sdkKey") + has_data = "data" in self._config + if sdk_key: + self.refresh_config(initial=True) + elif has_data: + data = self._config.get("data") or {} + self.data = data + self._data_manager.data = data + if data.get("error"): + return + self._initialized = True + self._event_manager.fire(SystemEvents.READY, None, None, True) + else: + self._event_manager.fire( + SystemEvents.READY, + {}, + ValueError(SDK_OR_DATA_OBJECT_REQUIRED), + True, + ) + + def refresh_config(self, *, initial: bool = False) -> dict[str, Any]: + data = self._api_manager.get_config() + if data.get("error"): + self.data = data + self._data_manager.data = data + return data + had_data = bool(self._data_manager.data) + self.data = data + self._data_manager.data = data + self._api_manager.set_data(data) + if not had_data and initial: + self._initialized = True + self._event_manager.fire(SystemEvents.READY, None, None, True) + elif had_data: + self._event_manager.fire(SystemEvents.CONFIG_UPDATED, None, None, True) + else: + self._initialized = True + self._event_manager.fire(SystemEvents.READY, None, None, True) + return data + + def refreshConfig(self) -> dict[str, Any]: + return self.refresh_config() + + def create_context( + self, + visitor_id: str, + visitor_attributes: Mapping[str, Any] | None = None, + ) -> Any | None: + if not self._initialized: + return None + from convertcom_sdk.context import Context + + return Context( + self._config, + visitor_id, + event_manager=self._event_manager, + experience_manager=self._experience_manager, + feature_manager=self._feature_manager, + segments_manager=self._segments_manager, + data_manager=self._data_manager, + api_manager=self._api_manager, + visitor_properties=visitor_attributes, + ) + + def createContext( + self, + visitor_id: str, + visitor_attributes: Mapping[str, Any] | None = None, + ) -> Any | None: + return self.create_context(visitor_id, visitor_attributes) + + def on(self, event: str, fn: Callable[[Any, Any], None]) -> None: + self._event_manager.on(event, fn) + + def on_ready(self) -> None: + if self._data_manager.data: + return None + raise ValueError(DATA_OBJECT_MISSING) + + def onReady(self) -> None: + return self.on_ready() diff --git a/src/convertcom_sdk/data/data_manager.py b/src/convertcom_sdk/data/data_manager.py index 158d588..d8980bc 100644 --- a/src/convertcom_sdk/data/data_manager.py +++ b/src/convertcom_sdk/data/data_manager.py @@ -18,12 +18,14 @@ def __init__( bucketing_manager: BucketingManager, rule_manager: RuleManager, data_store_manager: DataStoreManager | None = None, + api_manager: Any | None = None, ) -> None: self._config = dict(config or {}) self._data = self._config.get("data") or {} self._bucketing_manager = bucketing_manager self._rule_manager = rule_manager self._data_store_manager = data_store_manager + self._api_manager = api_manager self._environment = self._config.get("environment") self._account_id = self._data.get("account_id") self._project_id = (self._data.get("project") or {}).get("id") @@ -33,6 +35,20 @@ def __init__( def data(self) -> dict[str, Any]: return self._data + @data.setter + def data(self, value: Mapping[str, Any] | None) -> None: + if self.is_valid_config_data(value): + self._data = dict(value or {}) + self._account_id = self._data.get("account_id") + self._project_id = (self._data.get("project") or {}).get("id") + + @property + def data_store_manager(self) -> DataStoreManager | None: + return self._data_store_manager + + def set_api_manager(self, api_manager: Any | None) -> None: + self._api_manager = api_manager + def reset(self) -> None: self._bucketed_visitors = {} @@ -273,6 +289,7 @@ def _retrieve_bucketing( update_visitor_properties: bool, experience: Mapping[str, Any], force_variation_id: str | None = None, + enable_tracking: bool = True, ) -> Any: variation = None variation_id = None @@ -329,6 +346,18 @@ def _retrieve_bucketing( visitor_id, {"bucketing": {str(experience.get("id")): variation_id}}, ) + if enable_tracking and self._api_manager: + self._api_manager.enqueue( + visitor_id, + { + "eventType": "bucketing", + "data": { + "experienceId": str(experience.get("id")), + "variationId": str(variation_id), + }, + }, + (self.get_data(visitor_id) or {}).get("segments"), + ) variation = self._retrieve_variation(str(experience.get("id")), str(variation_id)) if not variation: @@ -360,6 +389,7 @@ def _get_bucketing_by_field( bool(attributes.get("updateVisitorProperties")), experience, attributes.get("forceVariationId"), + bool(attributes.get("enableTracking", True)), ) def get_bucketing( @@ -377,3 +407,62 @@ def get_bucketing_by_id( attributes: Mapping[str, Any] | None = None, ) -> Any: return self._get_bucketing_by_field(visitor_id, entity_id, "id", attributes) + + def convert( + self, + visitor_id: str, + goal_id: str, + goal_rule: Mapping[str, Any] | None = None, + goal_data: list[Mapping[str, Any]] | None = None, + segments: Mapping[str, Any] | None = None, + conversion_setting: Mapping[str, Any] | None = None, + ) -> RuleError | bool | None: + goal = self.get_entity(goal_id, "goals") or self.get_entity_by_id(goal_id, "goals") + if not goal or not goal.get("id"): + return None + + if goal_rule: + goal_rules = goal.get("rules") + if not goal_rules: + return None + rule_matched = self._rule_manager.is_rule_matched( + goal_rule, + goal_rules, + f"ConfigGoal #{goal_id}", + ) + if isinstance(rule_matched, RuleError): + return rule_matched + if not rule_matched: + return None + + store_data = self.get_data(visitor_id) or {} + bucketing_data = store_data.get("bucketing") + goals = dict(store_data.get("goals") or {}) + already_triggered = bool(goals.get(str(goal_id))) + force_multiple = bool( + (conversion_setting or {}).get("force_multiple_transactions") + or (conversion_setting or {}).get("forceMultipleTransactions") + ) + if already_triggered and not force_multiple: + return None + + self.put_data(visitor_id, {"goals": {str(goal_id): True}}) + + def enqueue_conversion(payload: dict[str, Any]) -> None: + if not self._api_manager: + return + if bucketing_data: + payload["bucketingData"] = bucketing_data + self._api_manager.enqueue( + visitor_id, + {"eventType": "conversion", "data": payload}, + segments, + ) + + if not already_triggered: + enqueue_conversion({"goalId": str(goal.get("id"))}) + + if goal_data and (not already_triggered or force_multiple): + enqueue_conversion({"goalId": str(goal.get("id")), "goalData": goal_data}) + + return True diff --git a/src/convertcom_sdk/data/data_store_manager.py b/src/convertcom_sdk/data/data_store_manager.py index 0717b3a..ad35141 100644 --- a/src/convertcom_sdk/data/data_store_manager.py +++ b/src/convertcom_sdk/data/data_store_manager.py @@ -31,3 +31,6 @@ def is_valid_data_store(self, data_store: Any) -> bool: and hasattr(data_store, "set") and callable(data_store.set) ) + + def release_queue(self, reason: str | None = None) -> None: + del reason diff --git a/src/convertcom_sdk/enums.py b/src/convertcom_sdk/enums.py index 1cdedd4..6d8b877 100644 --- a/src/convertcom_sdk/enums.py +++ b/src/convertcom_sdk/enums.py @@ -40,3 +40,13 @@ class SystemEvents(str, Enum): LOCATION_DEACTIVATED = "location.deactivated" AUDIENCES = "audiences" DATA_STORE_QUEUE_RELEASED = "datastore.queue.released" + + +class EntityType(str, Enum): + AUDIENCE = "audience" + LOCATION = "location" + SEGMENT = "segment" + FEATURE = "feature" + GOAL = "goal" + EXPERIENCE = "experience" + VARIATION = "variation" diff --git a/src/convertcom_sdk/errors.py b/src/convertcom_sdk/errors.py index 879c13f..4fe3b02 100644 --- a/src/convertcom_sdk/errors.py +++ b/src/convertcom_sdk/errors.py @@ -1,3 +1,7 @@ RULE_NOT_VALID = "Rule is not valid" RULE_MATCH_TYPE_NOT_SUPPORTED = "Rule match type is not supported" RULE_DATA_NOT_VALID = "Rule data is not valid" +SDK_OR_DATA_OBJECT_REQUIRED = "SDK key or Data object should be provided" +DATA_OBJECT_MISSING = "Data object is missing" +VISITOR_ID_REQUIRED = "Visitor string string is not present" +GOAL_DATA_NOT_VALID = "GoalData object is not valid" diff --git a/src/convertcom_sdk/events/event_manager.py b/src/convertcom_sdk/events/event_manager.py index bceecbb..46ac2b3 100644 --- a/src/convertcom_sdk/events/event_manager.py +++ b/src/convertcom_sdk/events/event_manager.py @@ -11,15 +11,20 @@ def __init__(self, config: Mapping[str, Any] | None = None) -> None: self._deferred: dict[str, dict[str, Any]] = {} self._mapper = config.get("mapper") or (lambda value: value) + def _event_key(self, event: Any) -> str: + return getattr(event, "value", event) + def on(self, event: str, fn: Callable[[Any, Any], None]) -> None: - self._listeners.setdefault(str(event), []).append(fn) - if str(event) in self._deferred: - deferred = self._deferred[str(event)] - self.fire(str(event), deferred.get("args"), deferred.get("err")) + event_key = self._event_key(event) + self._listeners.setdefault(event_key, []).append(fn) + if event_key in self._deferred: + deferred = self._deferred[event_key] + self.fire(event_key, deferred.get("args"), deferred.get("err")) def remove_listeners(self, event: str) -> None: - self._listeners.pop(str(event), None) - self._deferred.pop(str(event), None) + event_key = self._event_key(event) + self._listeners.pop(event_key, None) + self._deferred.pop(event_key, None) def fire( self, @@ -28,8 +33,9 @@ def fire( err: Any = None, deferred: bool = False, ) -> None: - listeners = list(self._listeners.get(str(event), [])) + event_key = self._event_key(event) + listeners = list(self._listeners.get(event_key, [])) for fn in listeners: fn(self._mapper(args), err) - if deferred and str(event) not in self._deferred: - self._deferred[str(event)] = {"args": args, "err": err} + if deferred and event_key not in self._deferred: + self._deferred[event_key] = {"args": args, "err": err} diff --git a/src/convertcom_sdk/sdk.py b/src/convertcom_sdk/sdk.py new file mode 100644 index 0000000..4715f0b --- /dev/null +++ b/src/convertcom_sdk/sdk.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from convertcom_sdk.api import ApiManager +from convertcom_sdk.bucketing import BucketingManager +from convertcom_sdk.config import build_config +from convertcom_sdk.core import Core +from convertcom_sdk.data import DataManager, DataStoreManager +from convertcom_sdk.events import EventManager +from convertcom_sdk.experience import ExperienceManager +from convertcom_sdk.features import FeatureManager +from convertcom_sdk.rules import RuleManager +from convertcom_sdk.segments import SegmentsManager + + +class ConvertSDK(Core): + def __init__( + self, + config: Mapping[str, Any] | None = None, + *, + request_sender: Any | None = None, + ) -> None: + configuration = build_config(config) + event_manager = EventManager(configuration) + api_manager = ApiManager( + configuration, + event_manager=event_manager, + request_sender=request_sender, + ) + bucketing_manager = BucketingManager(configuration) + rule_manager = RuleManager(configuration) + data_store_manager = None + if configuration.get("dataStore"): + data_store_manager = DataStoreManager( + configuration, + data_store=configuration.get("dataStore"), + ) + data_manager = DataManager( + configuration, + bucketing_manager=bucketing_manager, + rule_manager=rule_manager, + data_store_manager=data_store_manager, + api_manager=api_manager, + ) + experience_manager = ExperienceManager(configuration, data_manager=data_manager) + feature_manager = FeatureManager(configuration, data_manager=data_manager) + segments_manager = SegmentsManager( + configuration, + data_manager=data_manager, + rule_manager=rule_manager, + ) + super().__init__( + configuration, + data_manager=data_manager, + event_manager=event_manager, + experience_manager=experience_manager, + feature_manager=feature_manager, + segments_manager=segments_manager, + api_manager=api_manager, + ) diff --git a/tests/unit/test_context_public_api.py b/tests/unit/test_context_public_api.py new file mode 100644 index 0000000..e4bff08 --- /dev/null +++ b/tests/unit/test_context_public_api.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from convertcom_sdk import ConvertSDK, EntityType +from convertcom_sdk.utils import HttpResponse + + +def make_request_sender(requests): + def sender(*, method, base_url, route, headers, data): + requests.append( + { + "method": method, + "base_url": base_url, + "route": route, + "headers": headers, + "data": data, + } + ) + return HttpResponse( + status=200, + data={}, + headers={"Content-Type": "application/json"}, + ) + + return sender + + +def test_context_runs_experience_and_features(config): + requests = [] + sdk = ConvertSDK(config, request_sender=make_request_sender(requests)) + context = sdk.create_context("XXX", {"browser": "chrome", "country": "US"}) + + variation = context.run_experience( + "test-experience-ab-fullstack-2", + { + "locationProperties": {"url": "https://convert.com/"}, + "visitorProperties": {"varName3": "something"}, + }, + ) + features = context.run_features( + { + "locationProperties": {"url": "https://convert.com/"}, + "visitorProperties": {"varName3": "something"}, + "typeCasting": True, + } + ) + + assert variation["experienceKey"] == "test-experience-ab-fullstack-2" + assert len(features) >= 2 + context.release_queues("manual") + assert requests[0]["route"].startswith("/track/") + + +def test_context_tracks_conversion_and_release_queues(config): + requests = [] + sdk = ConvertSDK(config, request_sender=make_request_sender(requests)) + context = sdk.create_context("XXX") + + result = context.track_conversion( + "increase-engagement", + { + "ruleData": {"action": "buy"}, + "conversionData": [ + {"key": "amount", "value": 10.3}, + {"key": "productsCount", "value": 2}, + ], + }, + ) + context.release_queues("manual") + + assert result is True + assert requests[-1]["route"].startswith("/track/") + payload = requests[-1]["data"] + assert payload["visitors"][0]["events"][0]["eventType"] == "conversion" + + +def test_context_helpers(config): + sdk = ConvertSDK(config) + context = sdk.create_context("XXX") + + context.set_default_segments({"country": "UK"}) + context.run_custom_segments("test-segments-1", {"ruleData": {"enabled": True}}) + context.update_visitor_properties("XXX", {"weather": "rainy"}) + + assert context.get_config_entity("feature-2", EntityType.FEATURE)["id"] == "10025" + assert ( + context.get_config_entity_by_id("100299461", EntityType.VARIATION)["key"] + == "100299461-variation-1" + ) + assert context.get_visitor_data()["segments"]["weather"] == "rainy" diff --git a/tests/unit/test_core.py b/tests/unit/test_core.py new file mode 100644 index 0000000..3860ac5 --- /dev/null +++ b/tests/unit/test_core.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from convertcom_sdk import ConvertSDK, SystemEvents +from convertcom_sdk.utils import HttpResponse + + +def test_convert_sdk_creates_context_from_static_config(config): + sdk = ConvertSDK(config) + + context = sdk.create_context("visitor-1", {"browser": "chrome"}) + + assert context is not None + assert context.get_visitor_data() == {"segments": {"browser": "chrome"}} + + +def test_convert_sdk_fetches_config_from_sdk_key(): + requests = [] + + def request_sender(*, method, base_url, route, headers, data): + requests.append((method, base_url, route, headers, data)) + return HttpResponse( + status=200, + data={"account_id": "100", "project": {"id": "200"}, "features": []}, + headers={"Content-Type": "application/json"}, + ) + + sdk = ConvertSDK( + { + "sdkKey": "100/200", + "api": {"endpoint": {"config": "https://cdn.example.com"}}, + }, + request_sender=request_sender, + ) + + assert requests[0][2] == "/config/100/200?environment=staging" + assert sdk.create_context("visitor-1") is not None + + +def test_core_fires_ready_event_for_static_config(config): + sdk = ConvertSDK(config) + received = [] + + sdk.on(SystemEvents.READY, lambda args, err: received.append((args, err))) + + assert received == [(None, None)] + assert sdk.on_ready() is None