Skip to content

Commit 513c1ec

Browse files
sakshamg1304rohitesh-wingify
authored andcommitted
feat: attribute based targeting
1 parent 364f70d commit 513c1ec

File tree

12 files changed

+243
-61
lines changed

12 files changed

+243
-61
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file.
44
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## [1.13.0] - 2025-09-04
8+
9+
### Added
10+
11+
- Post-segmentation variables are now automatically included as unregistered attributes, enabling post-segmentation without requiring manual setup.
12+
- Added support for built-in targeting conditions, including browser version, OS version, and IP address, with advanced operator support (greaterThan, lessThan, regex).
13+
714
## [1.12.0] - 2025-09-02
815

916
### Added

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ def run(self):
121121

122122
setup(
123123
name="vwo-fme-python-sdk",
124-
version="1.12.0",
124+
version="1.13.0",
125125
description="VWO Feature Management and Experimentation SDK for Python",
126126
long_description=long_description,
127127
long_description_content_type="text/markdown",

vwo/constants/Constants.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ class Constants:
1717
# TODO: read from setup.py
1818
sdk_meta = {
1919
"name": "vwo-fme-python-sdk",
20-
"version": "1.12.0"
20+
"version": "1.13.0"
2121
}
2222

2323
# Constants

vwo/models/user/context_model.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515

1616
from .context_vwo_model import ContextVWOModel
17-
from typing import Dict
17+
from typing import Dict, List
1818

1919

2020
class ContextModel:
@@ -27,6 +27,7 @@ def __init__(self, context: Dict):
2727
"variation_targeting_variables", {}
2828
)
2929
self._vwo = ContextVWOModel(context.get("_vwo")) if "_vwo" in context else None
30+
self.post_segmentation_variables = context.get("post_segmentation_variables", [])
3031

3132
def get_id(self) -> str:
3233
return str(self.id) if self.id is not None else None
@@ -56,3 +57,9 @@ def get_vwo(self) -> ContextVWOModel:
5657

5758
def set_vwo(self, vwo: ContextVWOModel) -> None:
5859
self._vwo = vwo
60+
61+
def get_post_segmentation_variables(self) -> List[str]:
62+
return self.post_segmentation_variables
63+
64+
def set_post_segmentation_variables(self, post_segmentation_variables: List[str]) -> None:
65+
self.post_segmentation_variables = post_segmentation_variables

vwo/packages/segmentation_evaluator/core/segmentation_manager.py

Lines changed: 28 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
from ....models.campaign.feature_model import FeatureModel
2323
from ....models.user.context_model import ContextModel
2424
from ....utils.gateway_service_util import get_query_params, get_from_gateway_service
25+
from ....constants.Constants import Constants
26+
from ....services.url_service import UrlService
2527

2628

2729
class SegmentationManager:
@@ -77,27 +79,32 @@ def set_contextual_data(
7779
if not context.get_user_agent() and not context.get_ip_address():
7880
return
7981

80-
if (
81-
feature.get_is_gateway_service_required()
82-
): # Check if gateway service is required
83-
if SettingsManager.get_instance().is_gateway_service_provided and (
84-
context.get_vwo() is None
85-
):
86-
query_params = {}
87-
if context.get_user_agent():
88-
query_params["userAgent"] = context.get_user_agent()
89-
90-
if context.get_ip_address():
91-
query_params["ipAddress"] = context.get_ip_address()
92-
93-
try:
94-
params = get_query_params(query_params)
95-
_vwo = get_from_gateway_service(params, UrlEnum.GET_USER_DATA.value)
96-
context.set_vwo(ContextVWOModel(_vwo))
97-
except Exception as err:
98-
LogManager.get_instance().error(
99-
f"Error in setting contextual data for segmentation. Got error: {err}"
100-
)
82+
# Call gateway service if required for segmentation OR if gateway service is provided and user agent is available
83+
should_call_gateway_service = (
84+
(feature.get_is_gateway_service_required() and Constants.HOST_NAME not in UrlService.get_base_url()) or
85+
(Constants.HOST_NAME not in UrlService.get_base_url() and
86+
(context.get_user_agent() or context.get_ip_address()))
87+
)
88+
89+
if should_call_gateway_service and context.get_vwo() is None:
90+
query_params = {}
91+
if not context.get_user_agent() and not context.get_ip_address():
92+
return
93+
94+
if context.get_user_agent():
95+
query_params["userAgent"] = context.get_user_agent()
96+
97+
if context.get_ip_address():
98+
query_params["ipAddress"] = context.get_ip_address()
99+
100+
try:
101+
params = get_query_params(query_params)
102+
_vwo = get_from_gateway_service(params, UrlEnum.GET_USER_DATA.value)
103+
context.set_vwo(ContextVWOModel(_vwo))
104+
except Exception as err:
105+
LogManager.get_instance().error(
106+
f"Error in setting contextual data for segmentation. Got error: {err}"
107+
)
101108

102109
def validate_segmentation(self, dsl, properties):
103110
"""

vwo/packages/segmentation_evaluator/enums/segment_operand_regex_enum.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ class SegmentOperandRegexEnum(Enum):
2525
REGEX_MATCH = r"^regex\((.*)\)"
2626
STARTING_STAR = r"^\*"
2727
ENDING_STAR = r"\*$"
28-
GREATER_THAN_MATCH = r"^gt\((\d+\.?\d*|\.\d+)\)$"
29-
GREATER_THAN_EQUAL_TO_MATCH = r"^gte\((\d+\.?\d*|\.\d+)\)$"
30-
LESS_THAN_MATCH = r"^lt\((\d+\.?\d*|\.\d+)\)$"
31-
LESS_THAN_EQUAL_TO_MATCH = r"^lte\((\d+\.?\d*|\.\d+)\)$"
28+
GREATER_THAN_MATCH = r"^gt\(([\d.]+)\)$"
29+
GREATER_THAN_EQUAL_TO_MATCH = r"^gte\(([\d.]+)\)$"
30+
LESS_THAN_MATCH = r"^lt\(([\d.]+)\)$"
31+
LESS_THAN_EQUAL_TO_MATCH = r"^lte\(([\d.]+)\)$"

vwo/packages/segmentation_evaluator/enums/segment_operator_value_enum.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,6 @@ class SegmentOperatorValueEnum(Enum):
3131
BROWSER_AGENT = "browser_string"
3232
UA = "ua"
3333
FEATURE_ID = "featureId"
34+
IP = "ip_address"
35+
BROWSER_VERSION = "browser_version"
36+
OS_VERSION = "os_version"

vwo/packages/segmentation_evaluator/evaluators/segment_evaluator.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,18 @@ def is_segmentation_valid(self, dsl, properties):
5555
return SegmentOperandEvaluator().evaluate_user_agent_dsl(
5656
sub_dsl, self.context
5757
)
58+
elif operator == SegmentOperatorValueEnum.IP.value:
59+
return SegmentOperandEvaluator().evaluate_string_operand_dsl(
60+
sub_dsl, self.context, SegmentOperatorValueEnum.IP.value
61+
)
62+
elif operator == SegmentOperatorValueEnum.BROWSER_VERSION.value:
63+
return SegmentOperandEvaluator().evaluate_string_operand_dsl(
64+
sub_dsl, self.context, SegmentOperatorValueEnum.BROWSER_VERSION.value
65+
)
66+
elif operator == SegmentOperatorValueEnum.OS_VERSION.value:
67+
return SegmentOperandEvaluator().evaluate_string_operand_dsl(
68+
sub_dsl, self.context, SegmentOperatorValueEnum.OS_VERSION.value
69+
)
5870
else:
5971
return False
6072

vwo/packages/segmentation_evaluator/evaluators/segment_operand_evaluator.py

Lines changed: 139 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from ..utils.segment_util import get_key_value, match_with_regex
1919
from ..enums.segment_operand_regex_enum import SegmentOperandRegexEnum
2020
from ..enums.segment_operand_value_enum import SegmentOperandValueEnum
21+
from ..enums.segment_operator_value_enum import SegmentOperatorValueEnum
2122
from ...logger.core.log_manager import LogManager
2223
from ....utils.gateway_service_util import get_from_gateway_service
2324
from ....enums.url_enum import UrlEnum
@@ -244,48 +245,42 @@ def extract_result(self, operand_type: str, operand_value: str, tag_value: str):
244245
:param tag_value: The value of the tag to compare against.
245246
:return: The result of the evaluation.
246247
"""
248+
result = False
249+
247250
if tag_value is None:
248251
return False
249252

250253
# Ensure operand_value and tag_value are strings
251-
operand_value = str(operand_value)
252-
tag_value = str(tag_value)
254+
operand_value_str = str(operand_value)
255+
tag_value_str = str(tag_value)
256+
253257
if operand_type == SegmentOperandValueEnum.LOWER_VALUE.value:
254-
return operand_value.lower() == tag_value.lower()
258+
result = operand_value_str.lower() == tag_value_str.lower()
255259
elif operand_type == SegmentOperandValueEnum.STARTING_ENDING_STAR_VALUE.value:
256-
return operand_value in tag_value
260+
result = tag_value_str.find(operand_value_str) != -1
257261
elif operand_type == SegmentOperandValueEnum.STARTING_STAR_VALUE.value:
258-
return tag_value.endswith(operand_value)
262+
result = tag_value_str.endswith(operand_value_str)
259263
elif operand_type == SegmentOperandValueEnum.ENDING_STAR_VALUE.value:
260-
return tag_value.startswith(operand_value)
264+
result = tag_value_str.startswith(operand_value_str)
261265
elif operand_type == SegmentOperandValueEnum.REGEX_VALUE.value:
262266
try:
263-
pattern = re.compile(operand_value)
264-
return bool(pattern.search(tag_value))
267+
pattern = re.compile(operand_value_str)
268+
matcher = pattern.search(tag_value_str)
269+
result = matcher is not None
265270
except re.error:
266-
return False
271+
result = False
267272
elif operand_type == SegmentOperandValueEnum.GREATER_THAN_VALUE.value:
268-
try:
269-
return float(tag_value) > float(operand_value)
270-
except ValueError:
271-
return False
273+
result = self.compare_versions(tag_value_str, operand_value_str) > 0
272274
elif operand_type == SegmentOperandValueEnum.GREATER_THAN_EQUAL_TO_VALUE.value:
273-
try:
274-
return float(tag_value) >= float(operand_value)
275-
except ValueError:
276-
return False
275+
result = self.compare_versions(tag_value_str, operand_value_str) >= 0
277276
elif operand_type == SegmentOperandValueEnum.LESS_THAN_VALUE.value:
278-
try:
279-
return float(tag_value) < float(operand_value)
280-
except ValueError:
281-
return False
277+
result = self.compare_versions(tag_value_str, operand_value_str) < 0
282278
elif operand_type == SegmentOperandValueEnum.LESS_THAN_EQUAL_TO_VALUE.value:
283-
try:
284-
return float(tag_value) <= float(operand_value)
285-
except ValueError:
286-
return False
279+
result = self.compare_versions(tag_value_str, operand_value_str) <= 0
287280
else:
288-
return tag_value == operand_value
281+
result = tag_value_str == operand_value_str
282+
283+
return result
289284

290285
def convert_value(self, value):
291286
# Check if the value is a boolean
@@ -303,3 +298,121 @@ def convert_value(self, value):
303298
except ValueError:
304299
# Return the value as is if it's not a number
305300
return value
301+
302+
def evaluate_string_operand_dsl(self, dsl_operand_value, context: ContextModel, operand_type: SegmentOperatorValueEnum):
303+
"""
304+
Evaluates a given string tag value against a DSL operand value.
305+
306+
:param dsl_operand_value: The DSL operand string (e.g., "contains(\"value\")").
307+
:param context: The context object containing the value to evaluate.
308+
:param operand_type: The type of operand being evaluated (ip_address, browser_version, os_version).
309+
:return: True if tag value matches DSL operand criteria, false otherwise.
310+
"""
311+
operand = str(dsl_operand_value)
312+
313+
# Determine the tag value based on operand type
314+
tag_value = self.get_tag_value_for_operand_type(context, operand_type)
315+
316+
317+
if tag_value is None:
318+
self.log_missing_context_error(operand_type)
319+
return False
320+
321+
operand_type_and_value = self.pre_process_operand_value(operand)
322+
processed_values = self.process_values(operand_type_and_value["operand_value"], tag_value)
323+
tag_value = processed_values["tag_value"]
324+
325+
return self.extract_result(
326+
operand_type_and_value["operand_type"],
327+
processed_values["operand_value"].strip().replace('"', ''),
328+
tag_value
329+
)
330+
331+
def get_tag_value_for_operand_type(self, context: ContextModel, operand_type: SegmentOperatorValueEnum):
332+
"""
333+
Gets the appropriate tag value based on the operand type.
334+
335+
:param context: The context object.
336+
:param operand_type: The type of operand.
337+
:return: The tag value or None if not available.
338+
"""
339+
if operand_type == SegmentOperatorValueEnum.IP.value:
340+
return context.get_ip_address()
341+
elif operand_type == SegmentOperatorValueEnum.BROWSER_VERSION.value:
342+
return self.get_browser_version_from_context(context)
343+
else:
344+
# Default works for OS version
345+
return self.get_os_version_from_context(context)
346+
347+
def get_browser_version_from_context(self, context: ContextModel):
348+
"""
349+
Gets browser version from context.
350+
351+
:param context: The context object.
352+
:return: The browser version or None if not available.
353+
"""
354+
if context.get_vwo() is None or context.get_vwo().get_ua_info() is None or len(context.get_vwo().get_ua_info()) == 0:
355+
return None
356+
357+
user_agent = context.get_vwo().get_ua_info()
358+
359+
# Assuming UserAgent dictionary contains browser_version
360+
if "browser_version" in user_agent:
361+
return str(user_agent["browser_version"]) if user_agent["browser_version"] is not None else None
362+
return None
363+
364+
def get_os_version_from_context(self, context: ContextModel):
365+
"""
366+
Gets OS version from context.
367+
368+
:param context: The context object.
369+
:return: The OS version or None if not available.
370+
"""
371+
if context.get_vwo() is None or context.get_vwo().get_ua_info() is None or len(context.get_vwo().get_ua_info()) == 0:
372+
return None
373+
374+
user_agent = context.get_vwo().get_ua_info()
375+
# Assuming UserAgent dictionary contains os_version
376+
if "os_version" in user_agent:
377+
return str(user_agent["os_version"]) if user_agent["os_version"] is not None else None
378+
return None
379+
380+
def log_missing_context_error(self, operand_type: SegmentOperatorValueEnum):
381+
"""
382+
Logs appropriate error message for missing context.
383+
384+
:param operand_type: The type of operand.
385+
"""
386+
if operand_type == SegmentOperatorValueEnum.IP.value:
387+
LogManager.get_instance().info("To evaluate IP segmentation, please provide ipAddress in context")
388+
elif operand_type == SegmentOperatorValueEnum.BROWSER_VERSION.value:
389+
LogManager.get_instance().info("To evaluate browser version segmentation, please provide userAgent in context")
390+
else:
391+
LogManager.get_instance().info("To evaluate OS version segmentation, please provide userAgent in context")
392+
393+
def compare_versions(self, version1: str, version2: str):
394+
"""
395+
Compares two version strings using semantic versioning rules.
396+
Supports formats like "1.2.3", "1.0", "2.1.4.5", etc.
397+
398+
:param version1: First version string
399+
:param version2: Second version string
400+
:return: -1 if version1 < version2, 0 if equal, 1 if version1 > version2
401+
"""
402+
# Split versions by dots and convert to integers
403+
parts1 = [int(part) if part.isdigit() else 0 for part in version1.split('.')]
404+
parts2 = [int(part) if part.isdigit() else 0 for part in version2.split('.')]
405+
406+
# Find the maximum length to handle different version formats
407+
max_length = max(len(parts1), len(parts2))
408+
409+
for i in range(max_length):
410+
part1 = parts1[i] if i < len(parts1) else 0
411+
part2 = parts2[i] if i < len(parts2) else 0
412+
413+
if part1 < part2:
414+
return -1
415+
elif part1 > part2:
416+
return 1
417+
418+
return 0 # Versions are equal

vwo/utils/gateway_service_util.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ def get_query_params(query_params: Dict[str, Any]) -> Dict[str, str]:
6767
def add_is_gateway_service_required_flag(settings: SettingsModel) -> None:
6868
# Regex pattern to match the specified fields
6969
main_pattern = re.compile(
70-
r"\b(country|region|city|os|device_type|browser_string|ua)\b", re.IGNORECASE
70+
r"\b(country|region|city|os|device_type|browser_string|ua|os_version|browser_version)\b", re.IGNORECASE
7171
)
7272
# Regex pattern to match inlist(...) under custom_variable
7373
custom_variable_pattern = re.compile(r"inlist\([^)]*\)", re.IGNORECASE)

0 commit comments

Comments
 (0)