Skip to content

Commit 193d3c9

Browse files
feat: add odp integration w client and user context (#408)
* add main functionality for odp integraton w client and user context Co-authored-by: Andy Leap <andrew.leap@optimizely.com>
1 parent 92ab102 commit 193d3c9

22 files changed

+1060
-235
lines changed

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ documentation](https://docs.developers.optimizely.com/rollouts/docs).
2424

2525
## Getting Started
2626

27+
### Requirements
28+
29+
Version `4.0+`: Python 3.7+, PyPy 3.7+
30+
31+
Version `3.0+`: Python 2.7+, PyPy 3.4+
32+
2733
### Installing the SDK
2834

2935
The SDK is available through [PyPi](https://pypi.python.org/pypi?name=optimizely-sdk&:action=display).

optimizely/helpers/enums.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -199,13 +199,13 @@ class EventDispatchConfig:
199199
REQUEST_TIMEOUT: Final = 10
200200

201201

202-
class OdpRestApiConfig:
203-
"""ODP Rest API configs."""
202+
class OdpEventApiConfig:
203+
"""ODP Events API configs."""
204204
REQUEST_TIMEOUT: Final = 10
205205

206206

207-
class OdpGraphQLApiConfig:
208-
"""ODP GraphQL API configs."""
207+
class OdpSegmentApiConfig:
208+
"""ODP Segments API configs."""
209209
REQUEST_TIMEOUT: Final = 10
210210

211211

optimizely/helpers/sdk_settings.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Copyright 2022, Optimizely
2+
# Licensed under the Apache License, Version 2.0 (the "License");
3+
# you may not use this file except in compliance with the License.
4+
# You may obtain a copy of the License at
5+
#
6+
# https://www.apache.org/licenses/LICENSE-2.0
7+
#
8+
# Unless required by applicable law or agreed to in writing, software
9+
# distributed under the License is distributed on an "AS IS" BASIS,
10+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
# See the License for the specific language governing permissions and
12+
# limitations under the License.
13+
14+
15+
from typing import Optional
16+
17+
from optimizely.helpers import enums
18+
from optimizely.odp.lru_cache import OptimizelySegmentsCache
19+
from optimizely.odp.odp_event_manager import OdpEventManager
20+
from optimizely.odp.odp_segment_manager import OdpSegmentManager
21+
22+
23+
class OptimizelySdkSettings:
24+
"""Contains configuration used for Optimizely Project initialization."""
25+
26+
def __init__(
27+
self,
28+
odp_disabled: bool = False,
29+
segments_cache_size: int = enums.OdpSegmentsCacheConfig.DEFAULT_CAPACITY,
30+
segments_cache_timeout_in_secs: int = enums.OdpSegmentsCacheConfig.DEFAULT_TIMEOUT_SECS,
31+
odp_segments_cache: Optional[OptimizelySegmentsCache] = None,
32+
odp_segment_manager: Optional[OdpSegmentManager] = None,
33+
odp_event_manager: Optional[OdpEventManager] = None
34+
) -> None:
35+
"""
36+
Args:
37+
odp_disabled: Set this flag to true (default = False) to disable ODP features.
38+
segments_cache_size: The maximum size of audience segments cache (optional. default = 10,000).
39+
Set to zero to disable caching.
40+
segments_cache_timeout_in_secs: The timeout in seconds of audience segments cache (optional. default = 600).
41+
Set to zero to disable timeout.
42+
odp_segments_cache: A custom odp segments cache. Required methods include:
43+
`save(key, value)`, `lookup(key) -> value`, and `reset()`
44+
odp_segment_manager: A custom odp segment manager. Required method is:
45+
`fetch_qualified_segments(user_key, user_value, options)`.
46+
odp_event_manager: A custom odp event manager. Required method is:
47+
`send_event(type:, action:, identifiers:, data:)`
48+
"""
49+
50+
self.odp_disabled = odp_disabled
51+
self.segments_cache_size = segments_cache_size
52+
self.segments_cache_timeout_in_secs = segments_cache_timeout_in_secs
53+
self.segments_cache = odp_segments_cache
54+
self.odp_segment_manager = odp_segment_manager
55+
self.odp_event_manager = odp_event_manager

optimizely/helpers/validator.py

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@
2121
from optimizely.notification_center import NotificationCenter
2222
from optimizely.user_profile import UserProfile
2323
from . import constants
24+
from ..odp.lru_cache import OptimizelySegmentsCache
25+
from ..odp.odp_event_manager import OdpEventManager
26+
from ..odp.odp_segment_manager import OdpSegmentManager
2427

2528
if TYPE_CHECKING:
2629
# prevent circular dependenacy by skipping import at runtime
@@ -67,10 +70,10 @@ def _has_method(obj: object, method: str) -> bool:
6770
method: Method whose presence needs to be determined.
6871
6972
Returns:
70-
Boolean depending upon whether the method is available or not.
73+
Boolean depending upon whether the method is available and callable or not.
7174
"""
7275

73-
return getattr(obj, method, None) is not None
76+
return callable(getattr(obj, method, None))
7477

7578

7679
def is_config_manager_valid(config_manager: BaseConfigManager) -> bool:
@@ -312,3 +315,66 @@ def are_values_same_type(first_val: Any, second_val: Any) -> bool:
312315
def are_odp_data_types_valid(data: OdpDataDict) -> bool:
313316
valid_types = (str, int, float, bool, type(None))
314317
return all(isinstance(v, valid_types) for v in data.values())
318+
319+
320+
def is_segments_cache_valid(segments_cache: Optional[OptimizelySegmentsCache]) -> bool:
321+
""" Given a segments_cache determine if it is valid or not i.e. provides a reset, lookup and save methods.
322+
323+
Args:
324+
segments_cache: Provides cache methods: reset, lookup, save.
325+
326+
Returns:
327+
Boolean depending upon whether segments_cache is valid or not.
328+
"""
329+
if not _has_method(segments_cache, 'reset'):
330+
return False
331+
332+
if not _has_method(segments_cache, 'lookup'):
333+
return False
334+
335+
if not _has_method(segments_cache, 'save'):
336+
return False
337+
338+
return True
339+
340+
341+
def is_segment_manager_valid(segment_manager: Optional[OdpSegmentManager]) -> bool:
342+
""" Given a segments_manager determine if it is valid or not.
343+
344+
Args:
345+
segment_manager: Provides methods fetch_qualified_segments and reset
346+
347+
Returns:
348+
Boolean depending upon whether segments_manager is valid or not.
349+
"""
350+
if not _has_method(segment_manager, 'fetch_qualified_segments'):
351+
return False
352+
353+
if not _has_method(segment_manager, 'reset'):
354+
return False
355+
356+
return True
357+
358+
359+
def is_event_manager_valid(event_manager: Optional[OdpEventManager]) -> bool:
360+
""" Given an event_manager determine if it is valid or not.
361+
362+
Args:
363+
event_manager: Provides send_event method
364+
365+
Returns:
366+
Boolean depending upon whether event_manager is valid or not.
367+
"""
368+
if not hasattr(event_manager, 'is_running'):
369+
return False
370+
371+
if not _has_method(event_manager, 'send_event'):
372+
return False
373+
374+
if not _has_method(event_manager, 'stop'):
375+
return False
376+
377+
if not _has_method(event_manager, 'update_config'):
378+
return False
379+
380+
return True

optimizely/odp/zaius_rest_api_manager.py renamed to optimizely/odp/odp_event_api_manager.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from requests.exceptions import RequestException, ConnectionError, Timeout
2121

2222
from optimizely import logger as optimizely_logger
23-
from optimizely.helpers.enums import Errors, OdpRestApiConfig
23+
from optimizely.helpers.enums import Errors, OdpEventApiConfig
2424
from optimizely.odp.odp_event import OdpEvent, OdpEventEncoder
2525

2626
"""
@@ -37,7 +37,7 @@
3737
"""
3838

3939

40-
class ZaiusRestApiManager:
40+
class OdpEventApiManager:
4141
"""Provides an internal service for ODP event REST api access."""
4242

4343
def __init__(self, logger: Optional[optimizely_logger.Logger] = None):
@@ -69,7 +69,7 @@ def send_odp_events(self, api_key: str, api_host: str, events: list[OdpEvent]) -
6969
response = requests.post(url=url,
7070
headers=request_headers,
7171
data=payload_dict,
72-
timeout=OdpRestApiConfig.REQUEST_TIMEOUT)
72+
timeout=OdpEventApiConfig.REQUEST_TIMEOUT)
7373

7474
response.raise_for_status()
7575

optimizely/odp/odp_event_manager.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
from optimizely.helpers.enums import OdpEventManagerConfig, Errors, OdpManagerConfig
2424
from .odp_config import OdpConfig, OdpConfigState
2525
from .odp_event import OdpEvent, OdpDataDict
26-
from .zaius_rest_api_manager import ZaiusRestApiManager
26+
from .odp_event_api_manager import OdpEventApiManager
2727

2828

2929
class Signal(Enum):
@@ -45,7 +45,7 @@ class OdpEventManager:
4545
def __init__(
4646
self,
4747
logger: Optional[_logging.Logger] = None,
48-
api_manager: Optional[ZaiusRestApiManager] = None
48+
api_manager: Optional[OdpEventApiManager] = None
4949
):
5050
"""OdpEventManager init method to configure event batching.
5151
@@ -54,7 +54,7 @@ def __init__(
5454
api_manager: Optional component which sends events to ODP.
5555
"""
5656
self.logger = logger or _logging.NoOpLogger()
57-
self.zaius_manager = api_manager or ZaiusRestApiManager(self.logger)
57+
self.api_manager = api_manager or OdpEventApiManager(self.logger)
5858

5959
self.odp_config: Optional[OdpConfig] = None
6060
self.api_key: Optional[str] = None
@@ -158,7 +158,7 @@ def _flush_batch(self) -> None:
158158

159159
for i in range(1 + self.retry_count):
160160
try:
161-
should_retry = self.zaius_manager.send_odp_events(self.api_key, self.api_host, self._current_batch)
161+
should_retry = self.api_manager.send_odp_events(self.api_key, self.api_host, self._current_batch)
162162
except Exception as error:
163163
should_retry = False
164164
self.logger.error(Errors.ODP_EVENT_FAILED.format(f'Error: {error} {self._current_batch}'))

optimizely/odp/odp_manager.py

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
# you may not use this file except in compliance with the License.
44
# You may obtain a copy of the License at
55
#
6-
# http://www.apache.org/licenses/LICENSE-2.0
6+
# https://www.apache.org/licenses/LICENSE-2.0
77
#
88
# Unless required by applicable law or agreed to in writing, software
99
# distributed under the License is distributed on an "AS IS" BASIS,
@@ -15,7 +15,6 @@
1515

1616
from typing import Optional, Any
1717

18-
from optimizely import exceptions as optimizely_exception
1918
from optimizely import logger as optimizely_logger
2019
from optimizely.helpers.enums import Errors, OdpManagerConfig, OdpSegmentsCacheConfig
2120
from optimizely.helpers.validator import are_odp_data_types_valid
@@ -56,13 +55,8 @@ def __init__(
5655
)
5756
self.segment_manager = OdpSegmentManager(segments_cache, logger=self.logger)
5857

59-
if event_manager:
60-
self.event_manager = event_manager
61-
else:
62-
self.event_manager = OdpEventManager(self.logger)
63-
58+
self.event_manager = self.event_manager or OdpEventManager(self.logger)
6459
self.segment_manager.odp_config = self.odp_config
65-
self.event_manager.start(self.odp_config)
6660

6761
def fetch_qualified_segments(self, user_id: str, options: list[str]) -> Optional[list[str]]:
6862
if not self.enabled or not self.segment_manager:
@@ -94,17 +88,18 @@ def send_event(self, type: str, action: str, identifiers: dict[str, str], data:
9488
identifiers: A dictionary for identifiers.
9589
data: A dictionary for associated data. The default event data will be added to this data
9690
before sending to the ODP server.
97-
98-
Raises custom exception if error is detected.
9991
"""
10092
if not self.enabled or not self.event_manager:
101-
raise optimizely_exception.OdpNotEnabled(Errors.ODP_NOT_ENABLED)
93+
self.logger.error(Errors.ODP_NOT_ENABLED)
94+
return
10295

10396
if self.odp_config.odp_state() == OdpConfigState.NOT_INTEGRATED:
104-
raise optimizely_exception.OdpNotIntegrated(Errors.ODP_NOT_INTEGRATED)
97+
self.logger.error(Errors.ODP_NOT_INTEGRATED)
98+
return
10599

106100
if not are_odp_data_types_valid(data):
107-
raise optimizely_exception.OdpInvalidData(Errors.ODP_INVALID_DATA)
101+
self.logger.error(Errors.ODP_INVALID_DATA)
102+
return
108103

109104
self.event_manager.send_event(type, action, identifiers, data)
110105

@@ -122,5 +117,14 @@ def update_odp_config(self, api_key: Optional[str], api_host: Optional[str],
122117
if self.segment_manager:
123118
self.segment_manager.reset()
124119

125-
if self.event_manager:
120+
if not self.event_manager:
121+
return
122+
123+
if self.event_manager.is_running:
126124
self.event_manager.update_config()
125+
elif self.odp_config.odp_state() == OdpConfigState.INTEGRATED:
126+
self.event_manager.start(self.odp_config)
127+
128+
def close(self) -> None:
129+
if self.enabled and self.event_manager:
130+
self.event_manager.stop()

optimizely/odp/zaius_graphql_api_manager.py renamed to optimizely/odp/odp_segment_api_manager.py

Lines changed: 11 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from requests.exceptions import RequestException, ConnectionError, Timeout, JSONDecodeError
2121

2222
from optimizely import logger as optimizely_logger
23-
from optimizely.helpers.enums import Errors, OdpGraphQLApiConfig
23+
from optimizely.helpers.enums import Errors, OdpSegmentApiConfig
2424

2525
"""
2626
ODP GraphQL API
@@ -105,7 +105,7 @@
105105
"""
106106

107107

108-
class ZaiusGraphQLApiManager:
108+
class OdpSegmentApiManager:
109109
"""Interface for manging the fetching of audience segments."""
110110

111111
def __init__(self, logger: Optional[optimizely_logger.Logger] = None):
@@ -130,10 +130,15 @@ def fetch_segments(self, api_key: str, api_host: str, user_key: str,
130130
request_headers = {'content-type': 'application/json',
131131
'x-api-key': str(api_key)}
132132

133-
segments_filter = self.make_subset_filter(segments_to_check)
134133
query = {
135-
'query': 'query {customer(' + str(user_key) + ': "' + str(user_value) + '") '
136-
'{audiences' + segments_filter + ' {edges {node {name state}}}}}'
134+
'query':
135+
'query($userId: String, $audiences: [String]) {'
136+
f'customer({user_key}: $userId) '
137+
'{audiences(subset: $audiences) {edges {node {name state}}}}}',
138+
'variables': {
139+
'userId': str(user_value),
140+
'audiences': segments_to_check
141+
}
137142
}
138143

139144
try:
@@ -146,7 +151,7 @@ def fetch_segments(self, api_key: str, api_host: str, user_key: str,
146151
response = requests.post(url=url,
147152
headers=request_headers,
148153
data=payload_dict,
149-
timeout=OdpGraphQLApiConfig.REQUEST_TIMEOUT)
154+
timeout=OdpSegmentApiConfig.REQUEST_TIMEOUT)
150155

151156
response.raise_for_status()
152157
response_dict = response.json()
@@ -185,19 +190,3 @@ def fetch_segments(self, api_key: str, api_host: str, user_key: str,
185190
except KeyError:
186191
self.logger.error(Errors.FETCH_SEGMENTS_FAILED.format('decode error'))
187192
return None
188-
189-
@staticmethod
190-
def make_subset_filter(segments: list[str]) -> str:
191-
"""
192-
segments = []: (fetch none)
193-
--> subsetFilter = "(subset:[])"
194-
segments = ["a"]: (fetch one segment)
195-
--> subsetFilter = '(subset:["a"])'
196-
197-
Purposely using .join() method to deal with special cases of
198-
any words with apostrophes (i.e. don't). .join() method enquotes
199-
correctly without conflicting with the apostrophe.
200-
"""
201-
if segments == []:
202-
return '(subset:[])'
203-
return '(subset:["' + '", "'.join(segments) + '"]' + ')'

0 commit comments

Comments
 (0)