Skip to content
Draft
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
32 changes: 11 additions & 21 deletions mixpanel/flags/local_feature_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -318,29 +318,15 @@ def _get_assigned_rollout(

return None

def lowercase_keys_and_values(self, val: Any) -> Any:
if isinstance(val, str):
def _casefold_recursive(self, val: Any, include_keys: bool = False) -> Any:
if isinstance(val, str):
return val.casefold()
elif isinstance(val, list):
return [self.lowercase_keys_and_values(item) for item in val]
return [self._casefold_recursive(item, include_keys) for item in val]
elif isinstance(val, dict):
return {
(key.casefold() if isinstance(key, str) else key):
self.lowercase_keys_and_values(value)
for key, value in val.items()
}
else:
return val

def lowercase_only_leaf_nodes(self, val: Any) -> Dict[str, Any]:
if isinstance(val, str):
return val.casefold()
elif isinstance(val, list):
return [self.lowercase_only_leaf_nodes(item) for item in val]
elif isinstance(val, dict):
return {
key:
self.lowercase_only_leaf_nodes(value)
(key.casefold() if include_keys and isinstance(key, str) else key):
self._casefold_recursive(value, include_keys)
for key, value in val.items()
}
else:
Expand All @@ -351,7 +337,7 @@ def _get_runtime_parameters(self, context: Dict[str, Any]) -> Optional[Dict[str,
return None
if not isinstance(custom_properties, dict):
return None
return self.lowercase_keys_and_values(custom_properties)
return self._casefold_recursive(custom_properties, include_keys=True)

def _is_runtime_rules_engine_satisfied(self, rollout: Rollout, context: Dict[str, Any]) -> bool:
if rollout.runtime_evaluation_rule:
Expand All @@ -360,7 +346,7 @@ def _is_runtime_rules_engine_satisfied(self, rollout: Rollout, context: Dict[str
return False

try:
rule = self.lowercase_only_leaf_nodes(rollout.runtime_evaluation_rule)
rule = self._casefold_recursive(rollout.runtime_evaluation_rule)
result = json_logic.jsonLogic(rule, parameters_for_runtime_rule)
return bool(result)
except Exception:
Expand Down Expand Up @@ -487,6 +473,10 @@ def _track_exposure(
async def __aenter__(self):
return self

def shutdown(self):
self.stop_polling_for_definitions()
self._sync_client.close()

def __enter__(self):
return self

Expand Down
35 changes: 19 additions & 16 deletions mixpanel/flags/remote_feature_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ async def aget_all_variants(self, context: Dict[str, Any]) -> Optional[Dict[str,
self._instrument_call(start_time, end_time)
flags = self._handle_response(response)
except Exception:
logger.exception(f"Failed to get remote variants")
logger.exception("Failed to get remote variants")

return flags

Expand All @@ -74,15 +74,15 @@ async def aget_variant_value(
return variant.variant_value

async def aget_variant(
self, flag_key: str, fallback_value: SelectedVariant, context: Dict[str, Any], reportExposure: bool = True
self, flag_key: str, fallback_value: SelectedVariant, context: Dict[str, Any], report_exposure: bool = True
) -> SelectedVariant:
"""
Asynchronously gets the selected variant of a feature flag variant for the current user context from remote server.

:param str flag_key: The key of the feature flag to evaluate
:param SelectedVariant fallback_value: The default variant to return if evaluation fails
:param Dict[str, Any] context: Context dictionary containing user attributes and rollout context
:param bool reportExposure: Whether to report an exposure event if a variant is successfully retrieved
:param bool report_exposure: Whether to report an exposure event if a variant is successfully retrieved
"""
try:
params = self._prepare_query_params(context, flag_key)
Expand All @@ -94,7 +94,7 @@ async def aget_variant(
flags = self._handle_response(response)
selected_variant, is_fallback = self._lookup_flag_in_response(flag_key, flags, fallback_value)

if not is_fallback and reportExposure and (distinct_id := context.get("distinct_id")):
if not is_fallback and report_exposure and (distinct_id := context.get("distinct_id")):
properties = self._build_tracking_properties(
flag_key, selected_variant, start_time, end_time
)
Expand Down Expand Up @@ -132,7 +132,7 @@ async def atrack_exposure_event(
:param SelectedVariant variant: The selected variant for the feature flag
:param Dict[str, Any] context: The user context used to evaluate the feature flag
"""
if (distinct_id := context.get("distinct_id")):
if distinct_id := context.get("distinct_id"):
properties = self._build_tracking_properties(flag_key, variant)

await sync_to_async(self._tracker, thread_sensitive=False)(
Expand Down Expand Up @@ -160,7 +160,7 @@ def get_all_variants(self, context: Dict[str, Any]) -> Optional[Dict[str, Select
self._instrument_call(start_time, end_time)
flags = self._handle_response(response)
except Exception:
logger.exception(f"Failed to get remote variants")
logger.exception("Failed to get remote variants")

return flags

Expand All @@ -180,15 +180,15 @@ def get_variant_value(
return variant.variant_value

def get_variant(
self, flag_key: str, fallback_value: SelectedVariant, context: Dict[str, Any], reportExposure: bool = True
self, flag_key: str, fallback_value: SelectedVariant, context: Dict[str, Any], report_exposure: bool = True
) -> SelectedVariant:
"""
Synchronously gets the selected variant for a feature flag from remote server.

:param str flag_key: The key of the feature flag to evaluate
:param SelectedVariant fallback_value: The default variant to return if evaluation fails
:param Dict[str, Any] context: Context dictionary containing user attributes and rollout context
:param bool reportExposure: Whether to report an exposure event if a variant is successfully retrieved
:param bool report_exposure: Whether to report an exposure event if a variant is successfully retrieved
"""
try:
params = self._prepare_query_params(context, flag_key)
Expand All @@ -201,15 +201,15 @@ def get_variant(
flags = self._handle_response(response)
selected_variant, is_fallback = self._lookup_flag_in_response(flag_key, flags, fallback_value)

if not is_fallback and reportExposure and (distinct_id := context.get("distinct_id")):
if not is_fallback and report_exposure and (distinct_id := context.get("distinct_id")):
properties = self._build_tracking_properties(
flag_key, selected_variant, start_time, end_time
)
self._tracker(distinct_id, EXPOSURE_EVENT, properties)

return selected_variant
except Exception:
logging.exception(f"Failed to get remote variant for flag '{flag_key}'")
logger.exception(f"Failed to get remote variant for flag '{flag_key}'")
return fallback_value

def is_enabled(self, flag_key: str, context: Dict[str, Any]) -> bool:
Expand All @@ -235,11 +235,11 @@ def track_exposure_event(
:param SelectedVariant variant: The selected variant for the feature flag
:param Dict[str, Any] context: The user context used to evaluate the feature flag
"""
if (distinct_id := context.get("distinct_id")):
if distinct_id := context.get("distinct_id"):
properties = self._build_tracking_properties(flag_key, variant)
self._tracker(distinct_id, EXPOSURE_EVENT, properties)
else:
logging.error(
logger.error(
"Cannot track exposure event without a distinct_id in the context"
)

Expand All @@ -258,7 +258,7 @@ def _instrument_call(self, start_time: datetime, end_time: datetime) -> None:
request_duration = end_time - start_time
formatted_start_time = start_time.isoformat()
formatted_end_time = end_time.isoformat()
logging.debug(
logger.debug(
f"Request started at '{formatted_start_time}', completed at '{formatted_end_time}', duration: '{request_duration.total_seconds():.3f}s'"
)

Expand Down Expand Up @@ -298,22 +298,25 @@ def _lookup_flag_in_response(self, flag_key: str, flags: Dict[str, SelectedVaria
if flag_key in flags:
return flags[flag_key], False
else:
logging.debug(
logger.debug(
f"Flag '{flag_key}' not found in remote response. Returning fallback, '{fallback_value}'"
)
return fallback_value, True


def shutdown(self):
self._sync_client.close()

def __enter__(self):
return self

async def __aenter__(self):
return self

def __exit__(self, exc_type, exc_val, exc_tb):
logging.info("Exiting the RemoteFeatureFlagsProvider and cleaning up resources")
logger.info("Exiting the RemoteFeatureFlagsProvider and cleaning up resources")
self._sync_client.close()

async def __aexit__(self, exc_type, exc_val, exc_tb):
logging.info("Exiting the RemoteFeatureFlagsProvider and cleaning up resources")
logger.info("Exiting the RemoteFeatureFlagsProvider and cleaning up resources")
await self._async_client.aclose()
29 changes: 29 additions & 0 deletions openfeature-provider/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "mixpanel-openfeature"
version = "0.1.0"
description = "OpenFeature provider for the Mixpanel Python SDK"
license = "Apache-2.0"
authors = [
{name = "Mixpanel, Inc.", email = "dev@mixpanel.com"},
]
requires-python = ">=3.9"
dependencies = [
"mixpanel",
"openfeature-sdk>=0.7.0",
]

[project.optional-dependencies]
test = [
"pytest>=8.4.1",
"pytest-asyncio>=0.23.0",
]

[tool.setuptools.packages.find]
where = ["src"]

[tool.pytest.ini_options]
asyncio_mode = "auto"
3 changes: 3 additions & 0 deletions openfeature-provider/src/mixpanel_openfeature/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .provider import MixpanelProvider

__all__ = ["MixpanelProvider"]
Loading
Loading