-
Notifications
You must be signed in to change notification settings - Fork 0
feat(python-sdk): add deterministic bucketing and rule evaluation core #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| __pycache__/ | ||
| *.pyc |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| [build-system] | ||
| requires = ["setuptools>=68"] | ||
| build-backend = "setuptools.build_meta" | ||
|
|
||
| [project] | ||
| name = "convertcom-python-sdk" | ||
| version = "0.1.0" | ||
| description = "Python port of the Convert Fullstack SDK deterministic core" | ||
| readme = "README.md" | ||
| requires-python = ">=3.10" | ||
| dependencies = [] | ||
|
|
||
| [project.optional-dependencies] | ||
| dev = ["pytest>=8.0.0"] | ||
|
|
||
| [tool.setuptools] | ||
| package-dir = {"" = "src"} | ||
|
|
||
| [tool.setuptools.packages.find] | ||
| where = ["src"] | ||
|
|
||
| [tool.pytest.ini_options] | ||
| addopts = "-p no:cacheprovider" | ||
| pythonpath = ["src"] | ||
| testpaths = ["tests"] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| Metadata-Version: 2.4 | ||
| Name: convertcom-python-sdk | ||
| Version: 0.1.0 | ||
| Summary: Python port of the Convert Fullstack SDK deterministic core | ||
| Requires-Python: >=3.10 | ||
| Description-Content-Type: text/markdown | ||
| Provides-Extra: dev | ||
| Requires-Dist: pytest>=8.0.0; extra == "dev" | ||
|
|
||
| # Convert Python SDK | ||
|
|
||
| This package contains the first parity-focused slice of the Python Convert SDK: | ||
|
|
||
| - deterministic MurmurHash3-based bucketing | ||
| - rule comparisons | ||
| - rule traversal | ||
|
|
||
| Networking, tracking, config fetching, and the public SDK surface are intentionally left for follow-up PRs. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| README.md | ||
| pyproject.toml | ||
| src/convertcom_python_sdk.egg-info/PKG-INFO | ||
| src/convertcom_python_sdk.egg-info/SOURCES.txt | ||
| src/convertcom_python_sdk.egg-info/dependency_links.txt | ||
| src/convertcom_python_sdk.egg-info/requires.txt | ||
| src/convertcom_python_sdk.egg-info/top_level.txt | ||
| src/convertcom_sdk/__init__.py | ||
| src/convertcom_sdk/enums.py | ||
| src/convertcom_sdk/errors.py | ||
| src/convertcom_sdk/types.py | ||
| src/convertcom_sdk/bucketing/__init__.py | ||
| src/convertcom_sdk/bucketing/bucketing_manager.py | ||
| src/convertcom_sdk/rules/__init__.py | ||
| src/convertcom_sdk/rules/rule_manager.py | ||
| src/convertcom_sdk/utils/__init__.py | ||
| src/convertcom_sdk/utils/comparisons.py | ||
| src/convertcom_sdk/utils/hashing.py | ||
| src/convertcom_sdk/utils/object_utils.py | ||
| src/convertcom_sdk/utils/string_utils.py |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
|
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
|
|
||
| [dev] | ||
| pytest>=8.0.0 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| convertcom_sdk |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| from .bucketing.bucketing_manager import BucketingAllocation, BucketingManager | ||
| from .enums import BucketingError, RuleError | ||
| from .rules.rule_manager import RuleManager | ||
|
|
||
| __all__ = [ | ||
| "BucketingAllocation", | ||
| "BucketingError", | ||
| "BucketingManager", | ||
| "RuleError", | ||
| "RuleManager", | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| from .bucketing_manager import BucketingAllocation, BucketingManager | ||
|
|
||
| __all__ = ["BucketingAllocation", "BucketingManager"] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
| from __future__ import annotations | ||
|
|
||
| from dataclasses import dataclass | ||
| from typing import Any, Mapping | ||
|
|
||
| from convertcom_sdk.utils.string_utils import generate_hash | ||
|
|
||
|
|
||
| DEFAULT_HASH_SEED = 9999 | ||
| DEFAULT_MAX_TRAFFIC = 10000 | ||
| DEFAULT_MAX_HASH = 4294967296 | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class BucketingAllocation: | ||
| variation_id: str | ||
| bucketing_allocation: int | ||
|
|
||
|
|
||
| class BucketingManager: | ||
| def __init__(self, config: Mapping[str, Any] | None = None) -> None: | ||
| config = config or {} | ||
| bucketing = config.get("bucketing") or {} | ||
| self._max_traffic = int(bucketing.get("max_traffic") or DEFAULT_MAX_TRAFFIC) | ||
| self._hash_seed = int(bucketing.get("hash_seed") or DEFAULT_HASH_SEED) | ||
|
|
||
| def select_bucket( | ||
| self, | ||
| buckets: Mapping[str, int], | ||
| value: int, | ||
| redistribute: int = 0, | ||
| ) -> str | None: | ||
| selected: str | None = None | ||
| previous = 0 | ||
| for variation_id in buckets.keys(): | ||
| previous += buckets[variation_id] * 100 + redistribute | ||
| if value < previous: | ||
| selected = variation_id | ||
| break | ||
| return selected | ||
|
|
||
| def get_value_visitor_based( | ||
| self, | ||
| visitor_id: str, | ||
| options: Mapping[str, Any] | None = None, | ||
| ) -> int: | ||
| options = options or {} | ||
| seed = int(options.get("seed", self._hash_seed)) | ||
| experience_id = str(options.get("experienceId", "")) | ||
| hash_value = generate_hash(experience_id + str(visitor_id), seed) | ||
| value = (hash_value / DEFAULT_MAX_HASH) * self._max_traffic | ||
| return int(str(value).split(".", 1)[0]) | ||
|
|
||
| def get_bucket_for_visitor( | ||
| self, | ||
| buckets: Mapping[str, int], | ||
| visitor_id: str, | ||
| options: Mapping[str, Any] | None = None, | ||
| ) -> BucketingAllocation | None: | ||
| options = options or {} | ||
| value = self.get_value_visitor_based(visitor_id, options) | ||
| selected_bucket = self.select_bucket( | ||
| buckets, | ||
| value, | ||
| int(options.get("redistribute", 0)), | ||
| ) | ||
| if not selected_bucket: | ||
| return None | ||
| return BucketingAllocation( | ||
| variation_id=selected_bucket, | ||
| bucketing_allocation=value, | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| from enum import Enum | ||
|
|
||
|
|
||
| class RuleError(str, Enum): | ||
| NO_DATA_FOUND = "convert.com_no_data_found" | ||
| NEED_MORE_DATA = "convert.com_need_more_data" | ||
|
|
||
|
|
||
| class BucketingError(str, Enum): | ||
| VARIAION_NOT_DECIDED = "convert.com_variation_not_decided" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| 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" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| from .rule_manager import RuleManager | ||
|
|
||
| __all__ = ["RuleManager"] |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,135 @@ | ||||||||||||||||||||||||||||||
| from __future__ import annotations | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| from collections.abc import Mapping | ||||||||||||||||||||||||||||||
| from typing import Any | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| from convertcom_sdk.utils import DEFAULT_COMPARISON_PROCESSOR, camel_case | ||||||||||||||||||||||||||||||
| from convertcom_sdk.utils.object_utils import object_not_empty | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| DEFAULT_KEYS_CASE_SENSITIVE = True | ||||||||||||||||||||||||||||||
| DEFAULT_NEGATION = "!" | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| class RuleManager: | ||||||||||||||||||||||||||||||
| def __init__(self, config: Mapping[str, Any] | None = None) -> None: | ||||||||||||||||||||||||||||||
| config = config or {} | ||||||||||||||||||||||||||||||
| rules_config = config.get("rules") or {} | ||||||||||||||||||||||||||||||
| self._comparison_processor = ( | ||||||||||||||||||||||||||||||
| rules_config.get("comparisonProcessor") or DEFAULT_COMPARISON_PROCESSOR | ||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
| self._negation = str(rules_config.get("negation") or DEFAULT_NEGATION) | ||||||||||||||||||||||||||||||
| self._keys_case_sensitive = rules_config.get( | ||||||||||||||||||||||||||||||
| "keys_case_sensitive", DEFAULT_KEYS_CASE_SENSITIVE | ||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| @property | ||||||||||||||||||||||||||||||
| def comparison_processor(self) -> Mapping[str, Any]: | ||||||||||||||||||||||||||||||
| return self._comparison_processor | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| @comparison_processor.setter | ||||||||||||||||||||||||||||||
| def comparison_processor(self, comparison_processor: Mapping[str, Any]) -> None: | ||||||||||||||||||||||||||||||
| self._comparison_processor = comparison_processor | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| def get_comparison_processor_methods(self) -> list[str]: | ||||||||||||||||||||||||||||||
| return [ | ||||||||||||||||||||||||||||||
| name | ||||||||||||||||||||||||||||||
| for name, value in self._comparison_processor.items() | ||||||||||||||||||||||||||||||
| if callable(value) | ||||||||||||||||||||||||||||||
| ] | ||||||||||||||||||||||||||||||
|
Comment on lines
+34
to
+39
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This method recalculates the list of method names on every call. Since it's used inside loops during rule processing ( To implement this, you'll need to:
Suggested change
|
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| def is_valid_rule(self, rule: Mapping[str, Any]) -> bool: | ||||||||||||||||||||||||||||||
| matching = rule.get("matching") | ||||||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||||||
| isinstance(rule, Mapping) | ||||||||||||||||||||||||||||||
| and isinstance(matching, Mapping) | ||||||||||||||||||||||||||||||
| and isinstance(matching.get("match_type"), str) | ||||||||||||||||||||||||||||||
| and isinstance(matching.get("negated"), bool) | ||||||||||||||||||||||||||||||
| and "value" in rule | ||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| def is_rule_matched( | ||||||||||||||||||||||||||||||
| self, data: Any, rule_set: Mapping[str, Any], log_entry: str | None = None | ||||||||||||||||||||||||||||||
| ) -> bool: | ||||||||||||||||||||||||||||||
| del log_entry | ||||||||||||||||||||||||||||||
| match = False | ||||||||||||||||||||||||||||||
| if isinstance(rule_set, Mapping) and isinstance(rule_set.get("OR"), list) and rule_set["OR"]: | ||||||||||||||||||||||||||||||
| for item in rule_set["OR"]: | ||||||||||||||||||||||||||||||
| match = self._process_and(data, item) | ||||||||||||||||||||||||||||||
| if match is True: | ||||||||||||||||||||||||||||||
| return True | ||||||||||||||||||||||||||||||
| return bool(match) | ||||||||||||||||||||||||||||||
| return False | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| def _process_and(self, data: Any, rules_subset: Any) -> bool: | ||||||||||||||||||||||||||||||
| if isinstance(rules_subset, Mapping) and isinstance(rules_subset.get("AND"), list) and rules_subset["AND"]: | ||||||||||||||||||||||||||||||
| for item in rules_subset["AND"]: | ||||||||||||||||||||||||||||||
| match = self._process_or_when(data, item) | ||||||||||||||||||||||||||||||
| if match is not True: | ||||||||||||||||||||||||||||||
| return match | ||||||||||||||||||||||||||||||
| return True | ||||||||||||||||||||||||||||||
| return False | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| def _process_or_when(self, data: Any, rules_subset: Any) -> bool: | ||||||||||||||||||||||||||||||
| if isinstance(rules_subset, Mapping) and isinstance(rules_subset.get("OR_WHEN"), list) and rules_subset["OR_WHEN"]: | ||||||||||||||||||||||||||||||
| match = False | ||||||||||||||||||||||||||||||
| for item in rules_subset["OR_WHEN"]: | ||||||||||||||||||||||||||||||
| match = self._process_rule_item(data, item) | ||||||||||||||||||||||||||||||
| if match is True: | ||||||||||||||||||||||||||||||
| return True | ||||||||||||||||||||||||||||||
| return bool(match) | ||||||||||||||||||||||||||||||
| return False | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| def _process_rule_item(self, data: Any, rule: Any) -> bool: | ||||||||||||||||||||||||||||||
| if not isinstance(rule, Mapping) or not self.is_valid_rule(rule): | ||||||||||||||||||||||||||||||
| return False | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| negation = bool(rule["matching"].get("negated", False)) | ||||||||||||||||||||||||||||||
| matching = rule["matching"]["match_type"] | ||||||||||||||||||||||||||||||
| if matching not in self.get_comparison_processor_methods(): | ||||||||||||||||||||||||||||||
| return False | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| if not isinstance(data, Mapping): | ||||||||||||||||||||||||||||||
| return False | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| if self._is_using_custom_interface(data): | ||||||||||||||||||||||||||||||
| rule_type = rule.get("rule_type") | ||||||||||||||||||||||||||||||
| if rule_type: | ||||||||||||||||||||||||||||||
| rule_method = camel_case(f"get {rule_type.replace('_', ' ')}") | ||||||||||||||||||||||||||||||
| for method_name in dir(data): | ||||||||||||||||||||||||||||||
| if method_name == "__class__": | ||||||||||||||||||||||||||||||
| continue | ||||||||||||||||||||||||||||||
| method = getattr(data, method_name, None) | ||||||||||||||||||||||||||||||
| if not callable(method): | ||||||||||||||||||||||||||||||
| continue | ||||||||||||||||||||||||||||||
| mapper = getattr(data, "mapper", None) | ||||||||||||||||||||||||||||||
| mapped_name = mapper(method_name) if callable(mapper) else None | ||||||||||||||||||||||||||||||
| if method_name == rule_method or mapped_name == rule_method: | ||||||||||||||||||||||||||||||
| data_value = method(rule) | ||||||||||||||||||||||||||||||
| if rule_type == "js_condition": | ||||||||||||||||||||||||||||||
| return bool(data_value) | ||||||||||||||||||||||||||||||
| return bool( | ||||||||||||||||||||||||||||||
| self._comparison_processor[matching]( | ||||||||||||||||||||||||||||||
| data_value, | ||||||||||||||||||||||||||||||
| rule["value"], | ||||||||||||||||||||||||||||||
| negation, | ||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
| return False | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| if object_not_empty(data): | ||||||||||||||||||||||||||||||
| for key, value in data.items(): | ||||||||||||||||||||||||||||||
| left_key = key if self._keys_case_sensitive else str(key).lower() | ||||||||||||||||||||||||||||||
| rule_key = rule["key"] if self._keys_case_sensitive else str(rule["key"]).lower() | ||||||||||||||||||||||||||||||
| if left_key == rule_key: | ||||||||||||||||||||||||||||||
| return bool( | ||||||||||||||||||||||||||||||
| self._comparison_processor[matching]( | ||||||||||||||||||||||||||||||
| value, | ||||||||||||||||||||||||||||||
| rule["value"], | ||||||||||||||||||||||||||||||
| negation, | ||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
| return False | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| def _is_using_custom_interface(self, data: Any) -> bool: | ||||||||||||||||||||||||||||||
| return object_not_empty(data) and data.get("name") == "RuleData" | ||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| from __future__ import annotations | ||
|
|
||
| from dataclasses import dataclass | ||
| from typing import Any, Callable, Mapping, MutableMapping, TypedDict | ||
|
|
||
|
|
||
| ComparisonProcessor = Mapping[str, Callable[..., bool]] | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class BucketingHashOptions: | ||
| seed: int = 9999 | ||
| experience_id: str = "" | ||
| redistribute: int = 0 | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class BucketingAllocationType: | ||
| variation_id: str | ||
| bucketing_allocation: int | ||
|
Comment on lines
+17
to
+20
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||
|
|
||
|
|
||
| class RuleMatching(TypedDict): | ||
| match_type: str | ||
| negated: bool | ||
|
|
||
|
|
||
| class RuleElement(TypedDict, total=False): | ||
| key: str | ||
| rule_type: str | ||
| matching: RuleMatching | ||
| value: Any | ||
|
|
||
|
|
||
| RuleObject = MutableMapping[str, Any] | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| from .comparisons import DEFAULT_COMPARISON_PROCESSOR | ||
| from .string_utils import camel_case, generate_hash, is_numeric, to_number | ||
|
|
||
| __all__ = [ | ||
| "DEFAULT_COMPARISON_PROCESSOR", | ||
| "camel_case", | ||
| "generate_hash", | ||
| "is_numeric", | ||
| "to_number", | ||
| ] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This method of truncating a float to an integer is inefficient and not idiomatic Python. It involves converting the number to a string, splitting it, and then converting it back to an integer. A direct conversion using
int()is much cleaner and more performant.