diff --git a/src/convertcom_sdk/__init__.py b/src/convertcom_sdk/__init__.py index ce62608..91e9adb 100644 --- a/src/convertcom_sdk/__init__.py +++ b/src/convertcom_sdk/__init__.py @@ -1,11 +1,22 @@ +from .data.data_manager import DataManager +from .data.data_store_manager import DataStoreManager +from .experience.experience_manager import ExperienceManager +from .features.feature_manager import FeatureManager +from .segments.segments_manager import SegmentsManager from .bucketing.bucketing_manager import BucketingAllocation, BucketingManager -from .enums import BucketingError, RuleError +from .enums import BucketingError, FeatureStatus, RuleError from .rules.rule_manager import RuleManager __all__ = [ "BucketingAllocation", "BucketingError", "BucketingManager", + "DataManager", + "DataStoreManager", + "ExperienceManager", + "FeatureManager", + "FeatureStatus", "RuleError", "RuleManager", + "SegmentsManager", ] diff --git a/src/convertcom_sdk/data/__init__.py b/src/convertcom_sdk/data/__init__.py new file mode 100644 index 0000000..43088f8 --- /dev/null +++ b/src/convertcom_sdk/data/__init__.py @@ -0,0 +1,4 @@ +from .data_manager import DataManager +from .data_store_manager import DataStoreManager + +__all__ = ["DataManager", "DataStoreManager"] diff --git a/src/convertcom_sdk/data/data_manager.py b/src/convertcom_sdk/data/data_manager.py new file mode 100644 index 0000000..158d588 --- /dev/null +++ b/src/convertcom_sdk/data/data_manager.py @@ -0,0 +1,379 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from convertcom_sdk.bucketing import BucketingManager +from convertcom_sdk.data.data_store_manager import DataStoreManager +from convertcom_sdk.enums import BucketingError, RuleError, SegmentsKeys +from convertcom_sdk.rules import RuleManager +from convertcom_sdk.utils.object_utils import object_deep_merge, object_not_empty + + +class DataManager: + def __init__( + self, + config: Mapping[str, Any] | None = None, + *, + bucketing_manager: BucketingManager, + rule_manager: RuleManager, + data_store_manager: DataStoreManager | 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._environment = self._config.get("environment") + self._account_id = self._data.get("account_id") + self._project_id = (self._data.get("project") or {}).get("id") + self._bucketed_visitors: dict[str, dict[str, Any]] = {} + + @property + def data(self) -> dict[str, Any]: + return self._data + + def reset(self) -> None: + self._bucketed_visitors = {} + + def is_valid_config_data(self, data: Mapping[str, Any] | None) -> bool: + if not object_not_empty(data): + return False + return bool( + data.get("error") + or (data.get("account_id") and (data.get("project") or {}).get("id")) + ) + + def get_store_key(self, visitor_id: str) -> str: + return f"{self._account_id}-{self._project_id}-{visitor_id}" + + def get_data(self, visitor_id: str) -> dict[str, Any] | None: + store_key = self.get_store_key(visitor_id) + memory_data = self._bucketed_visitors.get(store_key) or {} + if self._data_store_manager: + stored = self._data_store_manager.get(store_key) or {} + return object_deep_merge(memory_data, stored) + return memory_data or None + + def put_data(self, visitor_id: str, new_data: Mapping[str, Any] | None = None) -> None: + new_data = dict(new_data or {}) + store_key = self.get_store_key(visitor_id) + current = self.get_data(visitor_id) or {} + updated = object_deep_merge(current, new_data) + self._bucketed_visitors[store_key] = updated + if self._data_store_manager: + self._data_store_manager.set(store_key, updated) + + def filter_report_segments( + self, visitor_properties: Mapping[str, Any] | None + ) -> dict[str, dict[str, Any] | None]: + visitor_properties = visitor_properties or {} + segment_keys = {item.value for item in SegmentsKeys} + segments: dict[str, Any] = {} + properties: dict[str, Any] = {} + for key, value in visitor_properties.items(): + if key in segment_keys: + segments[key] = value + else: + properties[key] = value + return { + "properties": properties or None, + "segments": segments or None, + } + + def get_entities_list(self, entity_type: str) -> list[Any]: + return list((self._data or {}).get(entity_type) or []) + + def get_entities_list_object(self, entity_type: str, field: str = "id") -> dict[str, Any]: + return { + str(entity[field]): entity + for entity in self.get_entities_list(entity_type) + if isinstance(entity, Mapping) and field in entity + } + + def _get_entity_by_field(self, identity: str, entity_type: str, identity_field: str = "key") -> Any: + for entity in self.get_entities_list(entity_type): + if isinstance(entity, Mapping) and str(entity.get(identity_field)) == str(identity): + return entity + return None + + def get_entity(self, key: str, entity_type: str) -> Any: + return self._get_entity_by_field(key, entity_type, "key") + + def get_entities(self, keys: list[str], entity_type: str) -> list[Any]: + return self.get_items_by_keys(keys, entity_type) + + def get_entity_by_id(self, entity_id: str, entity_type: str) -> Any: + return self._get_entity_by_field(entity_id, entity_type, "id") + + def get_entities_by_ids(self, ids: list[str], entity_type: str) -> list[Any]: + return self.get_items_by_ids(ids, entity_type) + + def get_items_by_keys(self, keys: list[str], path: str) -> list[Any]: + return [item for item in self.get_entities_list(path) if item.get("key") in keys] + + def get_items_by_ids(self, ids: list[str], path: str) -> list[Any]: + return [item for item in self.get_entities_list(path) if item.get("id") in ids] + + def get_sub_item( + self, + entity_type: str, + entity_identity: str, + sub_entity_type: str, + sub_entity_identity: str, + identity_field: str, + sub_identity_field: str, + ) -> Any: + entity = self._get_entity_by_field(entity_identity, entity_type, identity_field) + if not isinstance(entity, Mapping): + return None + for sub_entity in entity.get(sub_entity_type) or []: + if str(sub_entity.get(sub_identity_field)) == str(sub_entity_identity): + return sub_entity + return None + + def filter_matched_records_with_rule( + self, + items: list[Mapping[str, Any]], + data: Mapping[str, Any], + identity_field: str = "key", + ) -> list[Mapping[str, Any]]: + matched = [] + for item in items: + rules = item.get("rules") + if rules and self._rule_manager.is_rule_matched(data, rules, str(item.get(identity_field))): + matched.append(item) + return matched + + def filter_matched_custom_segments( + self, + items: list[Mapping[str, Any]], + visitor_id: str, + ) -> list[Mapping[str, Any]]: + store = self.get_data(visitor_id) or {} + custom_segments = ((store.get("segments") or {}).get(SegmentsKeys.CUSTOM_SEGMENTS.value)) or [] + return [item for item in items if item.get("id") in custom_segments] + + def select_locations( + self, + visitor_id: str, + items: list[Mapping[str, Any]], + *, + location_properties: Mapping[str, Any], + identity_field: str = "key", + ) -> list[Mapping[str, Any]]: + del visitor_id, identity_field + return self.filter_matched_records_with_rule(items, location_properties) + + def match_rules_by_field( + self, + visitor_id: str, + identity: str, + identity_field: str = "key", + attributes: Mapping[str, Any] | None = None, + ) -> Any: + attributes = attributes or {} + visitor_properties = attributes.get("visitorProperties") + location_properties = attributes.get("locationProperties") + ignore_location_properties = attributes.get("ignoreLocationProperties", False) + environment = attributes.get("environment", self._environment) + + experience = self._get_entity_by_field(identity, "experiences", identity_field) + if not isinstance(experience, Mapping): + return None + + archived = self.get_entities_list("archived_experiences") + if str(experience.get("id")) in {str(item) for item in archived}: + return None + + if experience.get("environment") and experience.get("environment") != environment: + return None + + location_matched = bool(ignore_location_properties) + if not location_matched: + if location_properties: + if experience.get("locations"): + locations = self.get_items_by_ids(experience["locations"], "locations") + location_matched = bool( + self.select_locations( + visitor_id, + locations, + location_properties=location_properties, + identity_field=identity_field, + ) + ) + elif experience.get("site_area"): + location_matched = bool( + self._rule_manager.is_rule_matched( + location_properties, experience["site_area"], "SiteArea" + ) + ) + else: + location_matched = True + else: + location_matched = not bool(experience.get("locations") or experience.get("site_area")) + if not location_matched: + return None + + store = self.get_data(visitor_id) or {} + existing_bucketing = store.get("bucketing") or {} + is_bucketed = str(experience.get("id")) in existing_bucketing + + audiences = self.get_items_by_ids(experience.get("audiences") or [], "audiences") + if visitor_properties: + audiences_to_check = [ + audience + for audience in audiences + if not (is_bucketed and audience.get("type") == "permanent") + ] + if audiences_to_check: + matched_audiences = self.filter_matched_records_with_rule( + audiences_to_check, + visitor_properties, + identity_field, + ) + matching_option = ( + ((experience.get("settings") or {}).get("matching_options") or {}).get("audiences") + or "any" + ) + audiences_matched = ( + len(matched_audiences) == len(audiences_to_check) + if matching_option == "all" + else bool(matched_audiences) + ) + else: + audiences_matched = True + else: + audiences_matched = not audiences + + segments = self.get_items_by_ids(experience.get("audiences") or [], "segments") + segments_matched = True + if segments: + segments_matched = bool(self.filter_matched_custom_segments(segments, visitor_id)) + + variations = experience.get("variations") or [] + if audiences_matched and segments_matched and variations: + return experience + return None + + def _retrieve_variation(self, experience_id: str, variation_id: str) -> Any: + return self.get_sub_item( + "experiences", + experience_id, + "variations", + variation_id, + "id", + "id", + ) + + def _retrieve_bucketing( + self, + visitor_id: str, + visitor_properties: Mapping[str, Any] | None, + update_visitor_properties: bool, + experience: Mapping[str, Any], + force_variation_id: str | None = None, + ) -> Any: + variation = None + variation_id = None + bucketing_allocation = None + if force_variation_id: + variation = self._retrieve_variation(str(experience.get("id")), str(force_variation_id)) + if variation: + variation_id = force_variation_id + + store = self.get_data(visitor_id) or {} + stored_variation_id = ((store.get("bucketing") or {}).get(str(experience.get("id")))) + if ( + stored_variation_id + and (variation_id is None or str(variation_id) == str(stored_variation_id)) + ): + variation = self._retrieve_variation(str(experience.get("id")), str(stored_variation_id)) + if variation: + variation_id = stored_variation_id + + if variation_id is None: + buckets: dict[str, float] = {} + for item in experience.get("variations") or []: + if not isinstance(item, Mapping) or not item.get("id"): + continue + if item.get("status") and item.get("status") != "running": + continue + traffic = item.get("traffic_allocation") + if traffic is None: + traffic = 100.0 + if traffic <= 0: + continue + buckets[str(item["id"])] = float(traffic) + bucketing = self._bucketing_manager.get_bucket_for_visitor( + buckets, + visitor_id, + None + if ((self._config.get("bucketing") or {}).get("excludeExperienceIdHash")) + else {"experienceId": str(experience.get("id"))}, + ) + if not bucketing: + return BucketingError.VARIAION_NOT_DECIDED + variation_id = bucketing.variation_id + bucketing_allocation = bucketing.bucketing_allocation + if update_visitor_properties and visitor_properties: + self.put_data( + visitor_id, + { + "bucketing": {str(experience.get("id")): variation_id}, + "segments": dict(visitor_properties), + }, + ) + else: + self.put_data( + visitor_id, + {"bucketing": {str(experience.get("id")): variation_id}}, + ) + variation = self._retrieve_variation(str(experience.get("id")), str(variation_id)) + + if not variation: + return None + return { + "experienceId": experience.get("id"), + "experienceName": experience.get("name"), + "experienceKey": experience.get("key"), + "bucketingAllocation": bucketing_allocation, + **variation, + } + + def _get_bucketing_by_field( + self, + visitor_id: str, + identity: str, + identity_field: str, + attributes: Mapping[str, Any] | None = None, + ) -> Any: + attributes = attributes or {} + experience = self.match_rules_by_field(visitor_id, identity, identity_field, attributes) + if not experience: + return None + if isinstance(experience, RuleError): + return experience + return self._retrieve_bucketing( + visitor_id, + attributes.get("visitorProperties"), + bool(attributes.get("updateVisitorProperties")), + experience, + attributes.get("forceVariationId"), + ) + + def get_bucketing( + self, + visitor_id: str, + key: str, + attributes: Mapping[str, Any] | None = None, + ) -> Any: + return self._get_bucketing_by_field(visitor_id, key, "key", attributes) + + def get_bucketing_by_id( + self, + visitor_id: str, + entity_id: str, + attributes: Mapping[str, Any] | None = None, + ) -> Any: + return self._get_bucketing_by_field(visitor_id, entity_id, "id", attributes) diff --git a/src/convertcom_sdk/data/data_store_manager.py b/src/convertcom_sdk/data/data_store_manager.py new file mode 100644 index 0000000..0717b3a --- /dev/null +++ b/src/convertcom_sdk/data/data_store_manager.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + + +class DataStoreManager: + def __init__( + self, + config: Mapping[str, Any] | None = None, + *, + data_store: Any = None, + ) -> None: + del config + self.data_store = data_store if self.is_valid_data_store(data_store) else None + + def set(self, key: str, data: Any) -> None: + if self.data_store is not None: + self.data_store.set(key, data) + + def get(self, key: str) -> Any: + if self.data_store is None: + return None + return self.data_store.get(key) + + def is_valid_data_store(self, data_store: Any) -> bool: + return bool( + data_store + and hasattr(data_store, "get") + and callable(data_store.get) + and hasattr(data_store, "set") + and callable(data_store.set) + ) diff --git a/src/convertcom_sdk/enums.py b/src/convertcom_sdk/enums.py index 6170e54..30f99ef 100644 --- a/src/convertcom_sdk/enums.py +++ b/src/convertcom_sdk/enums.py @@ -8,3 +8,22 @@ class RuleError(str, Enum): class BucketingError(str, Enum): VARIAION_NOT_DECIDED = "convert.com_variation_not_decided" + + +class FeatureStatus(str, Enum): + ENABLED = "enabled" + DISABLED = "disabled" + + +class VariationChangeType(str, Enum): + FULLSTACK_FEATURE = "fullStackFeature" + + +class SegmentsKeys(str, Enum): + COUNTRY = "country" + BROWSER = "browser" + DEVICES = "devices" + SOURCE = "source" + CAMPAIGN = "campaign" + VISITOR_TYPE = "visitorType" + CUSTOM_SEGMENTS = "customSegments" diff --git a/src/convertcom_sdk/experience/__init__.py b/src/convertcom_sdk/experience/__init__.py new file mode 100644 index 0000000..c05727f --- /dev/null +++ b/src/convertcom_sdk/experience/__init__.py @@ -0,0 +1,3 @@ +from .experience_manager import ExperienceManager + +__all__ = ["ExperienceManager"] diff --git a/src/convertcom_sdk/experience/experience_manager.py b/src/convertcom_sdk/experience/experience_manager.py new file mode 100644 index 0000000..45b26aa --- /dev/null +++ b/src/convertcom_sdk/experience/experience_manager.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from convertcom_sdk.data import DataManager + + +class ExperienceManager: + def __init__( + self, + config: Mapping[str, Any] | None = None, + *, + data_manager: DataManager, + ) -> None: + del config + self._data_manager = data_manager + + def get_list(self) -> list[dict[str, Any]]: + return self._data_manager.get_entities_list("experiences") + + def get_experience(self, key: str) -> dict[str, Any] | None: + return self._data_manager.get_entity(key, "experiences") + + def get_experience_by_id(self, entity_id: str) -> dict[str, Any] | None: + return self._data_manager.get_entity_by_id(entity_id, "experiences") + + def get_experiences(self, keys: list[str]) -> list[dict[str, Any]]: + return self._data_manager.get_items_by_keys(keys, "experiences") + + def select_variation( + self, + visitor_id: str, + experience_key: str, + attributes: Mapping[str, Any], + ) -> Any: + return self._data_manager.get_bucketing(visitor_id, experience_key, attributes) + + def select_variation_by_id( + self, + visitor_id: str, + experience_id: str, + attributes: Mapping[str, Any], + ) -> Any: + return self._data_manager.get_bucketing_by_id(visitor_id, experience_id, attributes) + + def select_variations( + self, + visitor_id: str, + attributes: Mapping[str, Any], + ) -> list[Any]: + variations = [] + for experience in self.get_list(): + variation = self.select_variation(visitor_id, experience.get("key"), attributes) + if isinstance(variation, dict): + variations.append(variation) + return variations + + def get_variation(self, experience_key: str, variation_key: str) -> dict[str, Any] | None: + return self._data_manager.get_sub_item( + "experiences", + experience_key, + "variations", + variation_key, + "key", + "key", + ) + + def get_variation_by_id(self, experience_id: str, variation_id: str) -> dict[str, Any] | None: + return self._data_manager.get_sub_item( + "experiences", + experience_id, + "variations", + variation_id, + "id", + "id", + ) diff --git a/src/convertcom_sdk/features/__init__.py b/src/convertcom_sdk/features/__init__.py new file mode 100644 index 0000000..9d8e90c --- /dev/null +++ b/src/convertcom_sdk/features/__init__.py @@ -0,0 +1,3 @@ +from .feature_manager import FeatureManager + +__all__ = ["FeatureManager"] diff --git a/src/convertcom_sdk/features/feature_manager.py b/src/convertcom_sdk/features/feature_manager.py new file mode 100644 index 0000000..0551798 --- /dev/null +++ b/src/convertcom_sdk/features/feature_manager.py @@ -0,0 +1,200 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from convertcom_sdk.data import DataManager +from convertcom_sdk.enums import BucketingError, FeatureStatus, RuleError, VariationChangeType +from convertcom_sdk.utils.type_utils import cast_type + + +class FeatureManager: + def __init__( + self, + config: Mapping[str, Any] | None = None, + *, + data_manager: DataManager, + ) -> None: + del config + self._data_manager = data_manager + + def get_list(self) -> list[dict[str, Any]]: + return self._data_manager.get_entities_list("features") + + def get_list_as_object(self, field: str = "id") -> dict[str, dict[str, Any]]: + return self._data_manager.get_entities_list_object("features", field) + + def get_feature(self, key: str) -> dict[str, Any] | None: + return self._data_manager.get_entity(key, "features") + + def get_feature_by_id(self, entity_id: str) -> dict[str, Any] | None: + return self._data_manager.get_entity_by_id(entity_id, "features") + + def get_features(self, keys: list[str]) -> list[dict[str, Any]]: + return self._data_manager.get_items_by_keys(keys, "features") + + def get_feature_variable_type(self, key: str, variable_name: str) -> str | None: + feature = self.get_feature(key) or {} + for variable in feature.get("variables") or []: + if variable.get("key") == variable_name: + return variable.get("type") + return None + + def get_feature_variable_type_by_id(self, entity_id: str, variable_name: str) -> str | None: + feature = self.get_feature_by_id(entity_id) or {} + for variable in feature.get("variables") or []: + if variable.get("key") == variable_name: + return variable.get("type") + return None + + def is_feature_declared(self, key: str) -> bool: + return bool(self._data_manager.get_entity(key, "features")) + + def run_feature( + self, + visitor_id: str, + feature_key: str, + attributes: Mapping[str, Any], + experience_keys: list[str] | None = None, + ) -> Any: + declared = self._data_manager.get_entity(feature_key, "features") + if not declared: + return {"key": feature_key, "status": FeatureStatus.DISABLED.value} + features = self.run_features( + visitor_id, + attributes, + {"features": [feature_key], "experiences": experience_keys}, + ) + if features: + return features[0] if len(features) == 1 else features + return { + "id": declared.get("id"), + "name": declared.get("name"), + "key": feature_key, + "status": FeatureStatus.DISABLED.value, + } + + def is_feature_enabled( + self, + visitor_id: str, + feature_key: str, + attributes: Mapping[str, Any], + experience_keys: list[str] | None = None, + ) -> bool: + if not self._data_manager.get_entity(feature_key, "features"): + return False + features = self.run_features( + visitor_id, + attributes, + {"features": [feature_key], "experiences": experience_keys}, + ) + return bool(features) + + def run_feature_by_id( + self, + visitor_id: str, + feature_id: str, + attributes: Mapping[str, Any], + experience_ids: list[str] | None = None, + ) -> Any: + declared = self._data_manager.get_entity_by_id(feature_id, "features") + if not declared: + return {"id": feature_id, "status": FeatureStatus.DISABLED.value} + experience_keys = None + if experience_ids: + experience_keys = [item.get("key") for item in self._data_manager.get_entities_by_ids(experience_ids, "experiences")] + features = self.run_features( + visitor_id, + attributes, + {"features": [declared.get("key")], "experiences": experience_keys}, + ) + if features: + return features[0] if len(features) == 1 else features + return { + "id": feature_id, + "name": declared.get("name"), + "key": declared.get("key"), + "status": FeatureStatus.DISABLED.value, + } + + def run_features( + self, + visitor_id: str, + attributes: Mapping[str, Any], + filter_by: Mapping[str, list[str]] | None = None, + ) -> list[dict[str, Any]]: + filter_by = filter_by or {} + type_casting = attributes.get("typeCasting", True) + declared_features = self.get_list_as_object("id") + bucketed_features: list[dict[str, Any]] = [] + + if filter_by.get("experiences"): + experiences = self._data_manager.get_entities(filter_by["experiences"], "experiences") + else: + experiences = self._data_manager.get_entities_list("experiences") + + bucketed_variations = [] + for experience in experiences: + variation = self._data_manager.get_bucketing(visitor_id, experience.get("key"), attributes) + if isinstance(variation, dict): + bucketed_variations.append(variation) + + for bucketed_variation in bucketed_variations: + for change in bucketed_variation.get("changes") or []: + if change.get("type") != VariationChangeType.FULLSTACK_FEATURE.value: + continue + changes = change.get("data") or {} + feature_id = changes.get("feature_id") + if not feature_id: + continue + feature = declared_features.get(str(feature_id)) + if not feature: + continue + if filter_by.get("features") and feature.get("key") not in filter_by["features"]: + continue + + variables = dict(changes.get("variables_data") or {}) + if type_casting: + for variable_name, variable_value in list(variables.items()): + variable_definition = next( + ( + item + for item in feature.get("variables") or [] + if item.get("key") == variable_name + ), + None, + ) + if variable_definition and variable_definition.get("type"): + variables[variable_name] = cast_type( + variable_value, variable_definition["type"] + ) + + bucketed_features.append( + { + "experienceId": bucketed_variation.get("experienceId"), + "experienceName": bucketed_variation.get("experienceName"), + "experienceKey": bucketed_variation.get("experienceKey"), + "key": feature.get("key"), + "name": feature.get("name"), + "id": str(feature_id), + "status": FeatureStatus.ENABLED.value, + "variables": variables, + } + ) + + if not filter_by.get("features"): + bucketed_feature_ids = {item["id"] for item in bucketed_features} + for feature in declared_features.values(): + if feature.get("id") not in bucketed_feature_ids: + bucketed_features.append( + { + "id": feature.get("id"), + "name": feature.get("name"), + "key": feature.get("key"), + "status": FeatureStatus.DISABLED.value, + } + ) + return bucketed_features + + def cast_type(self, value: object, kind: str) -> object: + return cast_type(value, kind) diff --git a/src/convertcom_sdk/segments/__init__.py b/src/convertcom_sdk/segments/__init__.py new file mode 100644 index 0000000..6e6cdd8 --- /dev/null +++ b/src/convertcom_sdk/segments/__init__.py @@ -0,0 +1,3 @@ +from .segments_manager import SegmentsManager + +__all__ = ["SegmentsManager"] diff --git a/src/convertcom_sdk/segments/segments_manager.py b/src/convertcom_sdk/segments/segments_manager.py new file mode 100644 index 0000000..4acd8d5 --- /dev/null +++ b/src/convertcom_sdk/segments/segments_manager.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from convertcom_sdk.data import DataManager +from convertcom_sdk.enums import SegmentsKeys +from convertcom_sdk.rules import RuleManager + + +class SegmentsManager: + def __init__( + self, + config: Mapping[str, Any] | None = None, + *, + data_manager: DataManager, + rule_manager: RuleManager, + ) -> None: + del config + self._data_manager = data_manager + self._rule_manager = rule_manager + + def get_segments(self, visitor_id: str) -> dict[str, Any] | None: + store_data = self._data_manager.get_data(visitor_id) or {} + filtered = self._data_manager.filter_report_segments(store_data.get("segments")) + return filtered["segments"] + + def put_segments(self, visitor_id: str, segments: Mapping[str, Any]) -> None: + filtered = self._data_manager.filter_report_segments(segments) + report_segments = filtered["segments"] + if report_segments: + self._data_manager.put_data(visitor_id, {"segments": report_segments}) + + def _set_custom_segments( + self, + visitor_id: str, + segments: list[Mapping[str, Any]], + segment_rule: Mapping[str, Any] | None = None, + ) -> dict[str, Any] | None: + store_data = self._data_manager.get_data(visitor_id) or {} + current_segments = dict(store_data.get("segments") or {}) + custom_segments = list(current_segments.get(SegmentsKeys.CUSTOM_SEGMENTS.value) or []) + + matched_ids: list[str] = [] + for segment in segments: + if not segment.get("id"): + continue + if segment_rule and not self._rule_manager.is_rule_matched( + segment_rule, segment.get("rules") or {}, f"ConfigSegment #{segment.get('id')}" + ): + continue + segment_id = str(segment["id"]) + if segment_id not in custom_segments: + matched_ids.append(segment_id) + + if matched_ids: + segments_data = { + **current_segments, + SegmentsKeys.CUSTOM_SEGMENTS.value: [*custom_segments, *matched_ids], + } + self.put_segments(visitor_id, segments_data) + return segments_data + return current_segments or None + + def select_custom_segments( + self, + visitor_id: str, + segment_keys: list[str], + segment_rule: Mapping[str, Any] | None = None, + ) -> dict[str, Any] | None: + segments = self._data_manager.get_entities(segment_keys, "segments") + return self._set_custom_segments(visitor_id, segments, segment_rule) + + def select_custom_segments_by_ids( + self, + visitor_id: str, + segment_ids: list[str], + segment_rule: Mapping[str, Any] | None = None, + ) -> dict[str, Any] | None: + segments = self._data_manager.get_entities_by_ids(segment_ids, "segments") + return self._set_custom_segments(visitor_id, segments, segment_rule) diff --git a/src/convertcom_sdk/utils/__init__.py b/src/convertcom_sdk/utils/__init__.py index c1b6da0..e11fadb 100644 --- a/src/convertcom_sdk/utils/__init__.py +++ b/src/convertcom_sdk/utils/__init__.py @@ -1,9 +1,11 @@ from .comparisons import DEFAULT_COMPARISON_PROCESSOR +from .type_utils import cast_type from .string_utils import camel_case, generate_hash, is_numeric, to_number __all__ = [ "DEFAULT_COMPARISON_PROCESSOR", "camel_case", + "cast_type", "generate_hash", "is_numeric", "to_number", diff --git a/src/convertcom_sdk/utils/object_utils.py b/src/convertcom_sdk/utils/object_utils.py index 208547b..e204d99 100644 --- a/src/convertcom_sdk/utils/object_utils.py +++ b/src/convertcom_sdk/utils/object_utils.py @@ -5,3 +5,17 @@ def object_not_empty(value: object) -> bool: return isinstance(value, Mapping) and len(value) > 0 + + +def object_deep_merge(*objects: object) -> dict: + result: dict = {} + for obj in objects: + if not isinstance(obj, Mapping): + continue + for key, value in obj.items(): + existing = result.get(key) + if isinstance(existing, Mapping) and isinstance(value, Mapping): + result[key] = object_deep_merge(existing, value) + else: + result[key] = value + return result diff --git a/src/convertcom_sdk/utils/type_utils.py b/src/convertcom_sdk/utils/type_utils.py new file mode 100644 index 0000000..5e775a6 --- /dev/null +++ b/src/convertcom_sdk/utils/type_utils.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import json + + +def cast_type(value: object, kind: str) -> object: + if kind == "boolean": + if value == "true": + return True + if value == "false": + return False + return bool(value) + if kind == "float": + if value is True: + return 1 + if value is False: + return 0 + return float(value) + if kind == "json": + if isinstance(value, (dict, list)): + return value + try: + return json.loads(str(value)) + except Exception: + return str(value) + if kind == "string": + return str(value) + if kind == "integer": + if value is True: + return 1 + if value is False: + return 0 + return int(float(str(value))) + return value diff --git a/tests/fixtures/test_config.json b/tests/fixtures/test_config.json new file mode 100644 index 0000000..9bf572c --- /dev/null +++ b/tests/fixtures/test_config.json @@ -0,0 +1,570 @@ +{ + "environment": "staging", + "data": { + "account_id": "10022898", + "audiences": [ + { + "id": "100299433", + "name": "Adv Audience", + "type": "transient", + "status": "active", + "key": "adv-audience", + "preset": false, + "rules": { + "OR": [ + { + "AND": [ + { + "OR_WHEN": [ + { + "rule_type": "generic_key_value", + "matching": { + "match_type": "matches", + "negated": false + }, + "value": "value1", + "key": "varName1" + } + ] + }, + { + "OR_WHEN": [ + { + "rule_type": "generic_key_value", + "matching": { + "match_type": "matches", + "negated": false + }, + "value": "value2", + "key": "varName2" + } + ] + } + ] + }, + { + "AND": [ + { + "OR_WHEN": [ + { + "rule_type": "generic_key_value", + "matching": { + "match_type": "matches", + "negated": false + }, + "value": "something", + "key": "varName3" + } + ] + } + ] + } + ] + } + } + ], + "segments": [ + { + "id": "200299434", + "name": "Test Segments", + "status": "active", + "key": "test-segments-1", + "preset": false, + "rules": { + "OR": [ + { + "AND": [ + { + "OR_WHEN": [ + { + "rule_type": "generic_key_value", + "matching": { + "match_type": "matches", + "negated": false + }, + "key": "enabled", + "value": true + } + ] + } + ] + } + ] + } + } + ], + "experiences": [ + { + "id": "100218245", + "name": "Test Experience AB Fullstack", + "key": "test-experience-ab-fullstack-2", + "type": "a/b_fullstack", + "version": 6, + "status": "active", + "global_js": "var s = 'test_experience'; console.log(s);", + "global_css": ".test-style { display: initial; }", + "url": "https://convert.com", + "integrations": [], + "environments": ["live", "staging"], + "site_area": { + "OR": [ + { + "AND": [ + { + "OR_WHEN": [ + { + "rule_type": "generic_key_value", + "matching": { + "match_type": "matches", + "negated": false + }, + "key": "url", + "value": "https://convert.com/" + } + ] + } + ] + } + ] + }, + "audiences": ["100299433"], + "goals": ["100215959", "100215960", "100215961"], + "settings": { + "matching_options": { + "audiences": "any" + } + }, + "variations": [ + { + "id": "100299456", + "name": "Original Page", + "status": "running", + "is_baseline": true, + "changes": [ + { + "id": "100240519", + "type": "fullStackFeature", + "data": { + "feature_id": "10024", + "variables_data": { + "enabled": "true", + "caption": "Click that" + } + } + }, + { + "id": "100240521", + "type": "fullStackFeature", + "data": { + "feature_id": "10025", + "variables_data": { + "price": 100, + "button-height": 40, + "additionalData": "{\"foo\":\"bar\",\"v\":2}" + } + } + } + ], + "key": "100299456-original-page", + "traffic_allocation": 50.0 + }, + { + "id": "100299457", + "name": "Variation 1", + "status": "running", + "is_baseline": false, + "changes": [ + { + "id": "100240520", + "type": "fullStackFeature", + "data": { + "feature_id": "10024", + "variables_data": { + "enabled": "false", + "caption": "Not allowed" + } + } + } + ], + "key": "100299457-variation-1", + "traffic_allocation": 50.0 + } + ] + }, + { + "id": "100218246", + "name": "Test Experience AB Fullstack", + "key": "test-experience-ab-fullstack-3", + "type": "a/b_fullstack", + "version": 6, + "status": "active", + "global_js": "var s = 'test_experience'; console.log(s);", + "global_css": ".test-style { display: initial; }", + "url": "https://convert.com", + "integrations": [], + "environments": ["live", "staging"], + "site_area": { + "OR": [ + { + "AND": [ + { + "OR_WHEN": [ + { + "rule_type": "generic_key_value", + "matching": { + "match_type": "matches", + "negated": false + }, + "key": "url", + "value": "https://convert.com/" + } + ] + } + ] + } + ] + }, + "audiences": ["100299433"], + "goals": ["100215959", "100215960", "100215961"], + "settings": { + "matching_options": { + "audiences": "any" + } + }, + "variations": [ + { + "id": "100299460", + "name": "Original Page", + "status": "running", + "is_baseline": true, + "changes": [ + { + "id": "100240529", + "type": "fullStackFeature", + "data": { + "feature_id": "10024", + "variables_data": { + "enabled": "true", + "caption": "Click that" + } + } + } + ], + "key": "100299460-original-page", + "traffic_allocation": 50.0 + }, + { + "id": "100299461", + "name": "Variation 1", + "status": "running", + "is_baseline": false, + "changes": [ + { + "id": "100240532", + "type": "fullStackFeature", + "data": { + "feature_id": "10024", + "variables_data": { + "enabled": "true", + "caption": "Allowed" + } + } + } + ], + "key": "100299461-variation-1", + "traffic_allocation": 50.0 + } + ] + }, + { + "id": "100218247", + "name": "Test Experience AB Fullstack", + "key": "test-experience-ab-fullstack-4", + "type": "a/b_fullstack", + "version": 6, + "status": "active", + "global_js": "var s = 'test_experience'; console.log(s);", + "global_css": ".test-style { display: initial; }", + "url": "https://convert.com", + "integrations": [], + "environments": ["live", "staging"], + "site_area": { + "OR": [ + { + "AND": [ + { + "OR_WHEN": [ + { + "rule_type": "generic_key_value", + "matching": { + "match_type": "matches", + "negated": false + }, + "key": "url", + "value": "https://convert.com/" + } + ] + } + ] + } + ] + }, + "audiences": ["100299433"], + "goals": ["100215959", "100215960", "100215961"], + "settings": { + "matching_options": { + "audiences": "any" + } + }, + "variations": [{}] + } + ], + "features": [ + { + "id": "10024", + "name": "Feature 1", + "key": "feature-1", + "variables": [ + { + "key": "enabled", + "type": "boolean" + }, + { + "key": "caption", + "type": "string" + } + ] + }, + { + "id": "10025", + "name": "Feature 2", + "key": "feature-2", + "variables": [ + { + "key": "price", + "type": "float" + }, + { + "key": "button-height", + "type": "integer" + }, + { + "key": "additionalData", + "type": "json" + } + ] + }, + { + "id": "10026", + "name": "Not Attached Feature 3", + "key": "not-attached-feature-3", + "variables": [ + { + "key": "fee", + "type": "float" + }, + { + "key": "link", + "type": "string" + }, + { + "key": "additionalData", + "type": "json" + } + ] + } + ], + "goals": [ + { + "id": "100215960", + "name": "Increase Engagement", + "selected_default": true, + "status": "active", + "type": "dom_interaction", + "is_system": true, + "key": "increase-engagement", + "settings": { + "tracked_items": [ + { + "event": "click", + "selector": "a" + }, + { + "event": "submit", + "selector": "form" + } + ] + }, + "rules": { + "OR": [ + { + "AND": [ + { + "OR_WHEN": [ + { + "rule_type": "generic_key_value", + "matching": { + "match_type": "matches", + "negated": false + }, + "key": "action", + "value": "buy" + }, + { + "rule_type": "generic_key_value", + "matching": { + "match_type": "matches", + "negated": false + }, + "key": "action", + "value": "signup" + } + ] + } + ] + } + ] + } + }, + { + "id": "100215959", + "name": "Decrease BounceRate", + "selected_default": true, + "status": "active", + "type": "advanced", + "is_system": true, + "key": "decrease-bounce-rate", + "rules": { + "OR": [ + { + "AND": [ + { + "OR_WHEN": [ + { + "rule_type": "generic_key_value", + "matching": { + "match_type": "lessEqual", + "negated": true + }, + "value": 1, + "key": "pages_visited_count" + }, + { + "rule_type": "generic_key_value", + "matching": { + "match_type": "lessEqual", + "negated": true + }, + "value": 10, + "key": "visit_duration" + } + ] + } + ] + } + ] + } + }, + { + "id": "100215961", + "name": "adv goal country browser", + "selected_default": false, + "status": "active", + "type": "advanced", + "is_system": false, + "key": "adv-goal-country-browser", + "rules": { + "OR": [ + { + "AND": [ + { + "OR_WHEN": [ + { + "rule_type": "generic_key_value", + "matching": { + "match_type": "equals", + "negated": false + }, + "value": "US", + "key": "country" + } + ] + }, + { + "OR_WHEN": [ + { + "rule_type": "generic_key_value", + "matching": { + "match_type": "equals", + "negated": false + }, + "value": "chrome", + "key": "browser_name" + } + ] + } + ] + }, + { + "AND": [ + { + "OR_WHEN": [ + { + "rule_type": "generic_key_value", + "matching": { + "match_type": "equals", + "negated": false + }, + "value": "GB", + "key": "country" + } + ] + }, + { + "OR_WHEN": [ + { + "rule_type": "generic_key_value", + "matching": { + "match_type": "equals", + "negated": false + }, + "value": "safari", + "key": "browser_name" + } + ] + } + ] + } + ] + } + }, + { + "id": "100215962", + "key": "goal-without-rule" + } + ], + "project": { + "id": "10025986", + "name": "Test Project", + "type": "fullstack", + "utc_offset": 0, + "domains": [ + { + "id": "10029181", + "hosts": "https://convert.com/", + "tld": false + } + ], + "settings": { + "auto_link": false, + "data_anonymization": false, + "do_not_track": "OFF", + "include_jquery": false + }, + "environments": { + "live": "Live", + "staging": "Staging" + } + } + } +} diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py new file mode 100644 index 0000000..2cb136f --- /dev/null +++ b/tests/unit/conftest.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from convertcom_sdk import ( + BucketingManager, + DataManager, + ExperienceManager, + FeatureManager, + RuleManager, + SegmentsManager, +) + + +@pytest.fixture +def config(): + fixture_path = Path(__file__).resolve().parents[1] / "fixtures" / "test_config.json" + return json.loads(fixture_path.read_text()) + + +@pytest.fixture +def managers(config): + bucketing_manager = BucketingManager(config) + rule_manager = RuleManager(config) + data_manager = DataManager( + config, + bucketing_manager=bucketing_manager, + rule_manager=rule_manager, + ) + segments_manager = SegmentsManager( + config, + data_manager=data_manager, + rule_manager=rule_manager, + ) + experience_manager = ExperienceManager(config, data_manager=data_manager) + feature_manager = FeatureManager(config, data_manager=data_manager) + return { + "bucketing_manager": bucketing_manager, + "rule_manager": rule_manager, + "data_manager": data_manager, + "segments_manager": segments_manager, + "experience_manager": experience_manager, + "feature_manager": feature_manager, + } diff --git a/tests/unit/test_data_manager.py b/tests/unit/test_data_manager.py new file mode 100644 index 0000000..038376d --- /dev/null +++ b/tests/unit/test_data_manager.py @@ -0,0 +1,71 @@ +from convertcom_sdk.enums import BucketingError + + +VISITOR_ID = "XXX" + + +def test_validates_fixture_config(managers, config): + data_manager = managers["data_manager"] + assert data_manager.is_valid_config_data(config["data"]) is True + + +def test_get_bucketing_by_key(managers): + data_manager = managers["data_manager"] + variation = data_manager.get_bucketing( + VISITOR_ID, + "test-experience-ab-fullstack-2", + { + "visitorProperties": {"varName3": "something"}, + "locationProperties": {"url": "https://convert.com/"}, + }, + ) + assert variation["experienceKey"] == "test-experience-ab-fullstack-2" + assert variation["id"] in {"100299456", "100299457"} + + +def test_get_bucketing_by_id(managers): + data_manager = managers["data_manager"] + variation = data_manager.get_bucketing_by_id( + VISITOR_ID, + "100218245", + { + "visitorProperties": {"varName3": "something"}, + "locationProperties": {"url": "https://convert.com/"}, + }, + ) + assert variation["experienceId"] == "100218245" + + +def test_get_entities_helpers(managers): + data_manager = managers["data_manager"] + features = data_manager.get_entities(["feature-1", "feature-2"], "features") + assert [feature["id"] for feature in features] == ["10024", "10025"] + + features_by_id = data_manager.get_entities_by_ids(["10024", "10025"], "features") + assert [feature["key"] for feature in features_by_id] == ["feature-1", "feature-2"] + + +def test_bucketing_returns_none_when_rules_do_not_match(managers): + data_manager = managers["data_manager"] + variation = data_manager.get_bucketing( + VISITOR_ID, + "test-experience-ab-fullstack-2", + { + "visitorProperties": {"varName3": "different"}, + "locationProperties": {"url": "https://example.com/"}, + }, + ) + assert variation is None + + +def test_bucketing_error_when_variations_missing(managers): + data_manager = managers["data_manager"] + variation = data_manager.get_bucketing( + VISITOR_ID, + "test-experience-ab-fullstack-4", + { + "visitorProperties": {"varName3": "something"}, + "locationProperties": {"url": "https://convert.com/"}, + }, + ) + assert variation == BucketingError.VARIAION_NOT_DECIDED diff --git a/tests/unit/test_experience_manager.py b/tests/unit/test_experience_manager.py new file mode 100644 index 0000000..8f97da0 --- /dev/null +++ b/tests/unit/test_experience_manager.py @@ -0,0 +1,50 @@ +VISITOR_ID = "XXX" + + +def test_experience_queries(managers, config): + experience_manager = managers["experience_manager"] + assert experience_manager.get_list() == config["data"]["experiences"] + assert experience_manager.get_experience("test-experience-ab-fullstack-2")["id"] == "100218245" + assert experience_manager.get_experience_by_id("100218245")["key"] == "test-experience-ab-fullstack-2" + + +def test_select_variation_and_variations(managers): + experience_manager = managers["experience_manager"] + attributes = { + "visitorProperties": {"varName3": "something"}, + "locationProperties": {"url": "https://convert.com/"}, + } + variation = experience_manager.select_variation( + VISITOR_ID, + "test-experience-ab-fullstack-2", + attributes, + ) + assert variation["experienceKey"] == "test-experience-ab-fullstack-2" + + variation_by_id = experience_manager.select_variation_by_id( + VISITOR_ID, + "100218245", + attributes, + ) + assert variation_by_id["experienceId"] == "100218245" + + variations = experience_manager.select_variations(VISITOR_ID, attributes) + assert len(variations) == 2 + assert {item["id"] for item in variations}.issubset( + {"100299456", "100299457", "100299460", "100299461"} + ) + + +def test_get_variations_by_key_and_id(managers): + experience_manager = managers["experience_manager"] + variation = experience_manager.get_variation( + "test-experience-ab-fullstack-2", + "100299457-variation-1", + ) + assert variation["id"] == "100299457" + + variation_by_id = experience_manager.get_variation_by_id( + "100218245", + "100299457", + ) + assert variation_by_id["key"] == "100299457-variation-1" diff --git a/tests/unit/test_feature_manager.py b/tests/unit/test_feature_manager.py new file mode 100644 index 0000000..ab110b3 --- /dev/null +++ b/tests/unit/test_feature_manager.py @@ -0,0 +1,61 @@ +VISITOR_ID = "XXX" + + +def test_feature_queries(managers, config): + feature_manager = managers["feature_manager"] + assert feature_manager.get_list() == config["data"]["features"] + assert feature_manager.get_list_as_object("id")["10024"]["key"] == "feature-1" + assert feature_manager.get_feature("feature-1")["id"] == "10024" + assert feature_manager.get_feature_by_id("10024")["key"] == "feature-1" + assert feature_manager.get_feature_variable_type("feature-1", "enabled") == "boolean" + assert feature_manager.get_feature_variable_type_by_id("10024", "enabled") == "boolean" + assert feature_manager.is_feature_declared("feature-1") is True + + +def test_run_feature_and_feature_enabled(managers): + feature_manager = managers["feature_manager"] + attributes = { + "visitorProperties": {"varName3": "something"}, + "locationProperties": {"url": "https://convert.com/"}, + } + features = feature_manager.run_feature(VISITOR_ID, "feature-1", attributes) + assert isinstance(features, list) + assert len(features) == 2 + assert {feature["id"] for feature in features} == {"10024"} + assert feature_manager.is_feature_enabled(VISITOR_ID, "feature-1", attributes) is True + + +def test_run_feature_by_id_and_run_features(managers): + feature_manager = managers["feature_manager"] + attributes = { + "visitorProperties": {"varName3": "something"}, + "locationProperties": {"url": "https://convert.com/"}, + "typeCasting": True, + } + features = feature_manager.run_feature_by_id(VISITOR_ID, "10024", attributes) + assert isinstance(features, list) + assert len(features) == 2 + + all_features = feature_manager.run_features( + VISITOR_ID, + attributes, + { + "features": ["feature-1", "feature-2", "not-attached-feature-3"], + "experiences": [ + "test-experience-ab-fullstack-2", + "test-experience-ab-fullstack-3", + ], + }, + ) + assert len(all_features) == 3 + assert {feature["id"] for feature in all_features}.issubset( + {"10024", "10025", "10026"} + ) + + +def test_feature_cast_type(managers): + feature_manager = managers["feature_manager"] + assert isinstance(feature_manager.cast_type("123", "integer"), int) + assert isinstance(feature_manager.cast_type(123, "string"), str) + assert isinstance(feature_manager.cast_type("1.23", "float"), float) + assert isinstance(feature_manager.cast_type("false", "boolean"), bool) diff --git a/tests/unit/test_segments_manager.py b/tests/unit/test_segments_manager.py new file mode 100644 index 0000000..ed8d16c --- /dev/null +++ b/tests/unit/test_segments_manager.py @@ -0,0 +1,28 @@ +VISITOR_ID = "XXX" + + +def test_put_and_get_segments(managers): + segments_manager = managers["segments_manager"] + segments_manager.put_segments( + VISITOR_ID, + { + "country": "US", + "browser": "chrome", + "varName3": "something", + }, + ) + assert segments_manager.get_segments(VISITOR_ID) == { + "country": "US", + "browser": "chrome", + } + + +def test_select_custom_segments(managers): + segments_manager = managers["segments_manager"] + segments_manager.select_custom_segments( + VISITOR_ID, + ["test-segments-1"], + {"enabled": True}, + ) + segments = segments_manager.get_segments(VISITOR_ID) + assert segments["customSegments"] == ["200299434"]