Skip to content

Commit 9fc53fd

Browse files
Kaustubh22327rohitesh-wingify
authored andcommitted
feat: support for web testing presegmentation
1 parent 5f6f888 commit 9fc53fd

12 files changed

Lines changed: 604 additions & 5 deletions

File tree

CHANGELOG.md

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

8+
## [1.60.0] - 2026-06-29
9+
10+
### Added
11+
12+
- Added support for `campaignVariation` segmentation operator to evaluate web testing campaign assignments. Context can now pass `platformVariables.webTestingCampaigns` (as a JSON object or JSON string) to target users based on their web testing campaign and variation assignments.
13+
814
## [1.55.0] - 2026-06-16
915

1016
### Added

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,9 +141,30 @@ context = {
141141
'user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36',
142142
'ip_address': '1.1.1.1',
143143
'session_id': 1697123456 # Optional: Custom session ID for web client integration
144+
'platformVariables': {
145+
# Reference example only:.
146+
# In production, fetch campaign assignments using script run in frontend and pass that object to backend as webTestingCampaigns.
147+
'webTestingCampaigns': '{"122":"1","130":"2"}'
148+
},
149+
'session_id': 1697123456 # Optional: Custom session ID for web client integration
144150
}
145151
```
146152

153+
## Web testing pre-segmentation
154+
155+
Server-side flag decisions can align with **Web Testing** (browser) experiments. **Campaign assignments are typically read on the frontend** (for example, VWO cookies) and sent to your server; pass them in `platformVariables.webTestingCampaigns` as a map of **campaign ID -> variation ID** (strings), or as a **JSON string** of that object.
156+
157+
Pre-segment rules in the VWO dashboard can use the **`campaignVariation`** operator:
158+
159+
| Operand pattern | Meaning |
160+
| --------------- | ------- |
161+
| `C` | User is in campaign `C` (any variation). |
162+
| `!C` | User is **not** in campaign `C`. |
163+
| `C_V` | User is in campaign `C` with variation `V`. |
164+
| `C_!V` | User is in campaign `C` and assigned variation is **not** `V`. |
165+
166+
Rollout rules evaluate pre-segments from the **first variation** only; AB/testing rules use **campaign-level** segments.
167+
147168
## Session Management
148169

149170
The SDK provides automatic session management capabilities to enable seamless integration with VWO's web client testing campaigns. Session IDs are automatically generated and managed to connect server-side feature flag decisions with client-side user sessions.

setup.py

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

144144
setup(
145145
name=_brand["name"],
146-
version="1.55.0",
146+
version="1.60.0",
147147
description=_brand["description"],
148148
long_description=long_description,
149149
long_description_content_type="text/markdown",
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
# Copyright 2024-2026 Wingify Software Pvt. Ltd.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import os
16+
import sys
17+
import unittest
18+
from unittest.mock import patch
19+
20+
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", "..")))
21+
22+
from wingify.models.user.context_model import ContextModel
23+
from wingify.packages.segmentation_evaluator.evaluators.segment_evaluator import (
24+
SegmentEvaluator,
25+
)
26+
from wingify.packages.segmentation_evaluator.core.segmentation_manager import (
27+
SegmentationManager,
28+
)
29+
from wingify.packages.segmentation_evaluator.utils.web_testing_segment_util import (
30+
evaluate_web_testing_campaign_variation,
31+
normalize_web_testing_campaigns_map,
32+
parse_web_testing_campaigns_from_context,
33+
)
34+
from wingify.services.settings_manager import SettingsManager
35+
36+
37+
def _context_with_platform_vars(extra: dict):
38+
base = {"id": "user-1", **extra}
39+
with patch.object(SettingsManager, "get_instance") as m:
40+
m.return_value.get_account_id.return_value = "1"
41+
return ContextModel(base)
42+
43+
44+
class TestWebTestingSegmentUtil(unittest.TestCase):
45+
def test_evaluate_variation_patterns(self):
46+
campaign_assignments_map = {"1": "1", "2": "2"}
47+
self.assertTrue(evaluate_web_testing_campaign_variation("1_1", campaign_assignments_map)[0])
48+
self.assertFalse(evaluate_web_testing_campaign_variation("1_1", campaign_assignments_map)[1])
49+
self.assertFalse(evaluate_web_testing_campaign_variation("1_2", campaign_assignments_map)[0])
50+
self.assertFalse(evaluate_web_testing_campaign_variation("99_1", campaign_assignments_map)[0])
51+
self.assertTrue(evaluate_web_testing_campaign_variation("1_!2", campaign_assignments_map)[0])
52+
self.assertFalse(evaluate_web_testing_campaign_variation("1_!1", campaign_assignments_map)[0])
53+
self.assertFalse(evaluate_web_testing_campaign_variation("99_!1", campaign_assignments_map)[0])
54+
self.assertTrue(evaluate_web_testing_campaign_variation("!99", campaign_assignments_map)[0])
55+
self.assertFalse(evaluate_web_testing_campaign_variation("!1", campaign_assignments_map)[0])
56+
57+
def test_null_map_like_node(self):
58+
self.assertTrue(evaluate_web_testing_campaign_variation("!1", None)[0])
59+
self.assertFalse(evaluate_web_testing_campaign_variation("1_1", None)[0])
60+
61+
def test_invalid_encoding(self):
62+
evaluation_result_pair = evaluate_web_testing_campaign_variation("bogus", {"1": "1"})
63+
self.assertFalse(evaluation_result_pair[0])
64+
self.assertTrue(evaluation_result_pair[1])
65+
66+
def test_multi_digit_ids(self):
67+
campaign_assignments_map = {"122": "4"}
68+
self.assertTrue(evaluate_web_testing_campaign_variation("122_4", campaign_assignments_map)[0])
69+
self.assertTrue(evaluate_web_testing_campaign_variation("122_!1", campaign_assignments_map)[0])
70+
self.assertFalse(evaluate_web_testing_campaign_variation("!122", campaign_assignments_map)[0])
71+
72+
def test_campaign_only_any_variation(self):
73+
self.assertTrue(
74+
evaluate_web_testing_campaign_variation("100", {"100": "1"})[0]
75+
)
76+
self.assertTrue(
77+
evaluate_web_testing_campaign_variation("100", {"100": "9"})[0]
78+
)
79+
self.assertFalse(evaluate_web_testing_campaign_variation("100", {})[0])
80+
self.assertFalse(
81+
evaluate_web_testing_campaign_variation("100", {"99": "1"})[0]
82+
)
83+
self.assertFalse(
84+
evaluate_web_testing_campaign_variation(
85+
"100", {"1": "1", "2": "2"}
86+
)[0]
87+
)
88+
89+
def test_normalize_coerces_types(self):
90+
self.assertEqual(
91+
normalize_web_testing_campaigns_map({129: 1, "14": 2}),
92+
{"129": "1", "14": "2"},
93+
)
94+
95+
96+
class TestWebTestingSegmentEvaluator(unittest.TestCase):
97+
def _evaluator(self, context: ContextModel):
98+
ev = SegmentEvaluator()
99+
ev.context = context
100+
return ev
101+
102+
def test_or_json_string_web_testing_campaigns(self):
103+
ctx = _context_with_platform_vars(
104+
{
105+
"platform_variables": {
106+
"web_testing_campaigns": '{"1":"1"}',
107+
}
108+
}
109+
)
110+
ev = self._evaluator(ctx)
111+
self.assertTrue(ev.is_segmentation_valid({"or": [{"campaignVariation": "1_1"}]}, {}))
112+
113+
def test_or_object_web_testing_campaigns(self):
114+
ctx = _context_with_platform_vars(
115+
{"platform_variables": {"web_testing_campaigns": {"1": 1}}}
116+
)
117+
ev = self._evaluator(ctx)
118+
self.assertTrue(ev.is_segmentation_valid({"or": [{"campaignVariation": "1_1"}]}, {}))
119+
120+
def test_not_in_campaign(self):
121+
ctx = _context_with_platform_vars(
122+
{"platform_variables": {"web_testing_campaigns": "{}"}}
123+
)
124+
ev = self._evaluator(ctx)
125+
self.assertTrue(ev.is_segmentation_valid({"or": [{"campaignVariation": "!1"}]}, {}))
126+
127+
def test_nested_not_campaign_variation(self):
128+
ctx = _context_with_platform_vars(
129+
{"platform_variables": {"web_testing_campaigns": '{"1":"1"}'}}
130+
)
131+
ev = self._evaluator(ctx)
132+
self.assertFalse(
133+
ev.is_segmentation_valid({"not": {"campaignVariation": "1_1"}}, {})
134+
)
135+
136+
def test_campaign_id_only(self):
137+
ctx = _context_with_platform_vars(
138+
{"platform_variables": {"web_testing_campaigns": '{"100":"2"}'}}
139+
)
140+
ev = self._evaluator(ctx)
141+
self.assertTrue(ev.is_segmentation_valid({"or": [{"campaignVariation": "100"}]}, {}))
142+
143+
def test_operand_trimmed(self):
144+
ctx = _context_with_platform_vars(
145+
{"platform_variables": {"web_testing_campaigns": '{"1":"1"}'}}
146+
)
147+
ev = self._evaluator(ctx)
148+
self.assertTrue(
149+
ev.is_segmentation_valid({"or": [{"campaignVariation": " 1_1 "}]}, {})
150+
)
151+
152+
def test_json_array_rejected(self):
153+
ctx = _context_with_platform_vars(
154+
{"platform_variables": {"web_testing_campaigns": "[]"}}
155+
)
156+
ev = self._evaluator(ctx)
157+
self.assertFalse(
158+
ev.is_segmentation_valid({"or": [{"campaignVariation": "1_1"}]}, {})
159+
)
160+
161+
def test_numeric_operand_coerced_to_string(self):
162+
ctx = _context_with_platform_vars(
163+
{"platform_variables": {"web_testing_campaigns": '{"1":"1"}'}}
164+
)
165+
ev = self._evaluator(ctx)
166+
self.assertTrue(ev.is_segmentation_valid({"or": [{"campaignVariation": 1}]}, {}))
167+
ctx_float = _context_with_platform_vars(
168+
{"platform_variables": {"web_testing_campaigns": '{"100":"2"}'}}
169+
)
170+
ev_float = self._evaluator(ctx_float)
171+
self.assertTrue(
172+
ev_float.is_segmentation_valid({"or": [{"campaignVariation": 100.0}]}, {})
173+
)
174+
175+
176+
class TestWebTestingValidateSegmentationPrecheck(unittest.TestCase):
177+
def test_precheck_false_when_campaign_variation_without_assignments_feed(self):
178+
ctx = _context_with_platform_vars({})
179+
segmentation_manager = SegmentationManager.get_instance()
180+
segmentation_manager.attach_evaluator()
181+
segmentation_manager.evaluator.context = ctx
182+
self.assertFalse(
183+
segmentation_manager.validate_segmentation(
184+
{"not": {"campaignVariation": "1_1"}}, {}
185+
)
186+
)
187+
188+
def test_precheck_true_when_campaign_variation_with_empty_assignment_object_string(
189+
self,
190+
):
191+
ctx = _context_with_platform_vars(
192+
{"platform_variables": {"web_testing_campaigns": "{}"}}
193+
)
194+
segmentation_manager = SegmentationManager.get_instance()
195+
segmentation_manager.attach_evaluator()
196+
segmentation_manager.evaluator.context = ctx
197+
self.assertTrue(
198+
segmentation_manager.validate_segmentation(
199+
{"or": [{"campaignVariation": "!1"}]}, {}
200+
)
201+
)
202+
203+
def test_duplicate_keys_in_json_string_last_value_wins(self):
204+
ctx = _context_with_platform_vars(
205+
{
206+
"platform_variables": {
207+
"web_testing_campaigns": '{"122":"1","122":"2"}',
208+
}
209+
}
210+
)
211+
campaigns_map_from_context = parse_web_testing_campaigns_from_context(ctx)
212+
self.assertEqual(campaigns_map_from_context, {"122": "2"})
213+
214+
215+
if __name__ == "__main__":
216+
unittest.main()

wingify/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.55.0",
20+
"version": "1.60.0",
2121
}
2222

2323
SDK_VERSION = sdk_meta["version"]

wingify/models/user/context_model.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414

1515

1616
from .context_vwo_model import ContextVWOModel
17-
from typing import Dict, List
17+
from typing import Any, Dict, List, Optional
18+
from ...utils.data_type_util import is_object
1819
from ...utils.uuid_util import get_uuid
1920
from ...services.settings_manager import SettingsManager
2021
from ...utils.function_util import get_current_unix_timestamp
@@ -33,6 +34,11 @@ def __init__(self, context: Dict):
3334
self._vwo = ContextVWOModel(vwo_context_data) if vwo_context_data else None
3435
self.post_segmentation_variables = context.get("post_segmentation_variables", [])
3536
self.bucketingSeed = context.get("bucketingSeed")
37+
self.platform_variables: Optional[Dict[str, Any]] = (
38+
dict(context.get("platform_variables"))
39+
if is_object(context.get("platform_variables"))
40+
else None
41+
)
3642
self.session_id = context.get("session_id", None)
3743
if self.session_id is None:
3844
self.session_id = get_current_unix_timestamp()
@@ -96,4 +102,12 @@ def set_session_id(self, session_id: int) -> None:
96102
self.session_id = session_id
97103

98104
def get_bucketing_seed(self) -> str:
99-
return self.bucketingSeed
105+
return self.bucketingSeed
106+
107+
def get_platform_variables(self) -> Optional[Dict[str, Any]]:
108+
return self.platform_variables
109+
110+
def set_platform_variables(self, platform_variables: Optional[Dict[str, Any]]) -> None:
111+
self.platform_variables = (
112+
dict(platform_variables) if is_object(platform_variables) else None
113+
)

wingify/packages/segmentation_evaluator/core/segmentation_manager.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@
2424
from ....utils.gateway_service_util import get_query_params, get_from_gateway_service
2525
from ....constants.Constants import Constants
2626
from ....enums.api_enum import ApiEnum
27+
from ..utils.web_testing_segment_util import (
28+
dsl_contains_campaign_variation_node,
29+
is_web_testing_campaigns_absent_or_null,
30+
)
2731

2832

2933
class SegmentationManager:
@@ -115,4 +119,11 @@ def validate_segmentation(self, dsl, properties):
115119
:param properties: The properties to validate against.
116120
:return: True if segmentation is valid, otherwise False.
117121
"""
122+
# if the dsl contains a campaign variation node, then we need to check if the campaign variation is valid
123+
if dsl_contains_campaign_variation_node(dsl):
124+
evaluation_context = getattr(self.evaluator, "context", None)
125+
if evaluation_context is None or is_web_testing_campaigns_absent_or_null(
126+
evaluation_context
127+
):
128+
return False
118129
return self.evaluator.is_segmentation_valid(dsl, properties)

wingify/packages/segmentation_evaluator/enums/segment_operator_value_enum.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,4 @@ class SegmentOperatorValueEnum(Enum):
3434
IP = "ip_address"
3535
BROWSER_VERSION = "browser_version"
3636
OS_VERSION = "os_version"
37+
WEB_CAMPAIGN_VARIATION = "campaignVariation"

wingify/packages/segmentation_evaluator/evaluators/segment_evaluator.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,10 @@ def is_segmentation_valid(self, dsl, properties):
6868
return SegmentOperandEvaluator().evaluate_string_operand_dsl(
6969
sub_dsl, self.context, SegmentOperatorValueEnum.OS_VERSION.value
7070
)
71+
elif operator == SegmentOperatorValueEnum.WEB_CAMPAIGN_VARIATION.value:
72+
return SegmentOperandEvaluator().evaluate_campaign_variation_dsl(
73+
sub_dsl, self.context
74+
)
7175
else:
7276
return False
7377

wingify/packages/segmentation_evaluator/evaluators/segment_operand_evaluator.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@
1616
import re
1717
from typing import Dict, Any
1818
from ..utils.segment_util import get_key_value, match_with_regex
19+
from ..utils.web_testing_segment_util import (
20+
coerce_campaign_variation_operand_to_str,
21+
parse_web_testing_campaigns_from_context,
22+
evaluate_web_testing_campaign_variation,
23+
)
1924
from ..enums.segment_operand_regex_enum import SegmentOperandRegexEnum
2025
from ..enums.segment_operand_value_enum import SegmentOperandValueEnum
2126
from ..enums.segment_operator_value_enum import SegmentOperatorValueEnum
@@ -84,6 +89,25 @@ def evaluate_custom_variable_dsl(
8489
operand_type, processed_values["operand_value"], tag_value
8590
)
8691

92+
def evaluate_campaign_variation_dsl(
93+
self, dsl_operand_value: Any, context: ContextModel
94+
) -> bool:
95+
"""
96+
Evaluates a campaignVariation DSL operand against the user's web testing campaign assignments.
97+
98+
:param dsl_operand_value: The raw operand value from the DSL (e.g. "123_1", "!123", 100).
99+
:param context: The context object containing platform_variables.web_testing_campaigns.
100+
:return: True if the segment condition is satisfied, otherwise False.
101+
"""
102+
operand_str = coerce_campaign_variation_operand_to_str(dsl_operand_value)
103+
if operand_str is None:
104+
return False
105+
106+
operand_str = operand_str.strip()
107+
campaigns_map = parse_web_testing_campaigns_from_context(context)
108+
result, _invalid = evaluate_web_testing_campaign_variation(operand_str, campaigns_map)
109+
return result
110+
87111
def evaluate_user_dsl(self, dsl_operand_value, properties):
88112
"""
89113
Evaluates a user DSL expression to check if a user ID is in a specified List.

0 commit comments

Comments
 (0)