Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .DS_Store
Binary file not shown.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
__pycache__/
*.pyc
25 changes: 25 additions & 0 deletions pyproject.toml
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"]
18 changes: 18 additions & 0 deletions src/convertcom_python_sdk.egg-info/PKG-INFO
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.
20 changes: 20 additions & 0 deletions src/convertcom_python_sdk.egg-info/SOURCES.txt
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
1 change: 1 addition & 0 deletions src/convertcom_python_sdk.egg-info/dependency_links.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

3 changes: 3 additions & 0 deletions src/convertcom_python_sdk.egg-info/requires.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@

[dev]
pytest>=8.0.0
1 change: 1 addition & 0 deletions src/convertcom_python_sdk.egg-info/top_level.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
convertcom_sdk
11 changes: 11 additions & 0 deletions src/convertcom_sdk/__init__.py
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",
]
3 changes: 3 additions & 0 deletions src/convertcom_sdk/bucketing/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .bucketing_manager import BucketingAllocation, BucketingManager

__all__ = ["BucketingAllocation", "BucketingManager"]
72 changes: 72 additions & 0 deletions src/convertcom_sdk/bucketing/bucketing_manager.py
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])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
return int(str(value).split(".", 1)[0])
return int(value)


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,
)
10 changes: 10 additions & 0 deletions src/convertcom_sdk/enums.py
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"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There is a typo in the enum member name VARIAION_NOT_DECIDED. It should be VARIATION_NOT_DECIDED.

Suggested change
VARIAION_NOT_DECIDED = "convert.com_variation_not_decided"
VARIATION_NOT_DECIDED = "convert.com_variation_not_decided"

3 changes: 3 additions & 0 deletions src/convertcom_sdk/errors.py
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"
3 changes: 3 additions & 0 deletions src/convertcom_sdk/rules/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .rule_manager import RuleManager

__all__ = ["RuleManager"]
135 changes: 135 additions & 0 deletions src/convertcom_sdk/rules/rule_manager.py
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This method recalculates the list of method names on every call. Since it's used inside loops during rule processing (_process_rule_item), this can be inefficient. It's better to cache this list and invalidate the cache only when the comparison_processor is updated.

To implement this, you'll need to:

  1. Add self._comparison_methods_cache: list[str] | None = None to __init__.
  2. Invalidate the cache in the comparison_processor setter by setting self._comparison_methods_cache = None.
  3. Update this method to use the cache.
Suggested change
def get_comparison_processor_methods(self) -> list[str]:
return [
name
for name, value in self._comparison_processor.items()
if callable(value)
]
def get_comparison_processor_methods(self) -> list[str]:
if self._comparison_methods_cache is None:
self._comparison_methods_cache = [
name
for name, value in self._comparison_processor.items()
if callable(value)
]
return self._comparison_methods_cache


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"
35 changes: 35 additions & 0 deletions src/convertcom_sdk/types.py
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The BucketingAllocationType dataclass appears to be a duplicate of BucketingAllocation defined in src/convertcom_sdk/bucketing/bucketing_manager.py. To adhere to the DRY (Don't Repeat Yourself) principle and improve maintainability, this type should be defined in a single location (e.g., here in types.py) and imported wherever it's needed.



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]
10 changes: 10 additions & 0 deletions src/convertcom_sdk/utils/__init__.py
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",
]
Loading