-
Notifications
You must be signed in to change notification settings - Fork 88
Python: Add OpenFeature provider #156
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
7e267c8
5135505
ece5e75
124f6b1
a34bf6e
001dfab
a511391
6a32f84
571af47
e601641
1c40800
baa48db
247b539
3c721d5
6058162
b6719df
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,98 @@ | ||
| [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>=5.1.0,<6", | ||
| "openfeature-sdk>=0.7.0", | ||
| ] | ||
|
|
||
| [project.optional-dependencies] | ||
| test = [ | ||
| "pytest>=8.4.1", | ||
| "pytest-asyncio>=0.23.0", | ||
| "pytest-cov>=6.0", | ||
| ] | ||
|
|
||
| [tool.setuptools.packages.find] | ||
| where = ["src"] | ||
|
|
||
| [tool.pytest.ini_options] | ||
| asyncio_mode = "auto" | ||
|
|
||
| # --- Ruff configuration (mirrors main project) --- | ||
|
|
||
| [tool.ruff] | ||
| target-version = "py39" | ||
| line-length = 88 | ||
|
|
||
| [tool.ruff.lint] | ||
| select = ["ALL"] | ||
| ignore = [ | ||
| # --- Rule conflicts --- | ||
| "D203", # conflicts with D211 (no-blank-line-before-class) | ||
| "D213", # conflicts with D212 (multi-line-summary-first-line) | ||
| "COM812", # conflicts with ruff formatter | ||
| "ISC001", # conflicts with ruff formatter | ||
|
|
||
| # --- Type annotations (separate effort) --- | ||
| "ANN", # all annotation rules | ||
|
|
||
| # --- Docstrings (separate effort) --- | ||
| "D100", # undocumented-public-module | ||
| "D101", # undocumented-public-class | ||
| "D102", # undocumented-public-method | ||
| "D103", # undocumented-public-function | ||
| "D104", # undocumented-public-package | ||
| "D105", # undocumented-magic-method | ||
| "D107", # undocumented-public-init | ||
|
|
||
| # --- Boolean arguments (public API) --- | ||
| "FBT", # boolean-type-hint / boolean-default / boolean-positional | ||
|
|
||
| # --- TODO/FIXME enforcement --- | ||
| "TD002", # missing-todo-author | ||
| "TD003", # missing-todo-link | ||
| "FIX001", # line-contains-fixme | ||
| "FIX002", # line-contains-todo | ||
|
|
||
| # --- Exception message style --- | ||
| "EM101", # raw-string-in-exception | ||
| "EM103", # dot-format-in-exception | ||
| "TRY003", # raise-vanilla-args | ||
|
|
||
| # --- Other pragmatic exclusions --- | ||
| "PLR0913", # too-many-arguments | ||
| "PLR0911", # too-many-return-statements (_resolve has many type-check branches) | ||
| "E501", # line-too-long (formatter handles code) | ||
| "FA100", # future-rewritable-type-annotation | ||
| "BLE001", # blind-exception (catching Exception in flag resolution is intentional) | ||
| ] | ||
|
|
||
| [tool.ruff.lint.per-file-ignores] | ||
| "tests/*.py" = [ | ||
| "S101", # assert | ||
| "S105", # hardcoded-password-string (test fixtures) | ||
| "S106", # hardcoded-password-func-arg | ||
| "SLF001", # private-member-access | ||
| "PLR2004", # magic-value-comparison | ||
| "D", # all docstring rules | ||
| "PT018", # pytest-composite-assertion | ||
| "INP001", # implicit-namespace-package (no __init__.py in tests) | ||
| "ARG", # unused arguments (lambda stubs in mocks) | ||
| ] | ||
|
|
||
| [tool.ruff.lint.isort] | ||
| known-first-party = ["mixpanel", "mixpanel_openfeature"] | ||
|
|
||
| [tool.ruff.lint.pydocstyle] | ||
| convention = "google" | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| from .provider import MixpanelProvider | ||
|
|
||
| __all__ = ["MixpanelProvider"] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,176 @@ | ||
| import math | ||
| import typing | ||
| from collections.abc import Mapping, Sequence | ||
| from typing import Union | ||
|
|
||
| from openfeature.evaluation_context import EvaluationContext | ||
| from openfeature.exception import ErrorCode | ||
| from openfeature.flag_evaluation import FlagResolutionDetails, Reason | ||
| from openfeature.provider import AbstractProvider, Metadata | ||
|
|
||
| from mixpanel.flags.types import SelectedVariant | ||
|
|
||
| FlagValueType = Union[bool, str, int, float, list, dict, None] | ||
|
|
||
|
|
||
| class MixpanelProvider(AbstractProvider): | ||
| """An OpenFeature provider backed by a Mixpanel feature flags provider.""" | ||
|
|
||
| def __init__(self, flags_provider: typing.Any) -> None: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since the primary purpose of the openfeatureprovider is to not have to worry about handling the internals of the providers behind the scene, I think the init should just take in properties we need to initialize the mixpanel library and the requested flags provider
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is a really good point, and for a lot of other OpenFeature providers, it looks like this is how things are handled. I have a couple concerns though (kind of more in a general sense, and not specifically with Python):
I personally don't think it's too painful to pass in the mixpanel client, since it's just one more line, but I do understand that this deviates from the usual pattern. What do you think @efahk ? |
||
| super().__init__() | ||
| self._flags_provider = flags_provider | ||
|
|
||
| def get_metadata(self) -> Metadata: | ||
| return Metadata(name="mixpanel-provider") | ||
|
|
||
| def shutdown(self) -> None: | ||
| self._flags_provider.shutdown() | ||
|
|
||
| def resolve_boolean_details( | ||
| self, | ||
| flag_key: str, | ||
| default_value: bool, | ||
| evaluation_context: typing.Optional[EvaluationContext] = None, | ||
| ) -> FlagResolutionDetails[bool]: | ||
| return self._resolve(flag_key, default_value, bool, evaluation_context) | ||
|
|
||
| def resolve_string_details( | ||
| self, | ||
| flag_key: str, | ||
| default_value: str, | ||
| evaluation_context: typing.Optional[EvaluationContext] = None, | ||
| ) -> FlagResolutionDetails[str]: | ||
| return self._resolve(flag_key, default_value, str, evaluation_context) | ||
|
|
||
| def resolve_integer_details( | ||
| self, | ||
| flag_key: str, | ||
| default_value: int, | ||
| evaluation_context: typing.Optional[EvaluationContext] = None, | ||
| ) -> FlagResolutionDetails[int]: | ||
| return self._resolve(flag_key, default_value, int, evaluation_context) | ||
|
|
||
| def resolve_float_details( | ||
| self, | ||
| flag_key: str, | ||
| default_value: float, | ||
| evaluation_context: typing.Optional[EvaluationContext] = None, | ||
| ) -> FlagResolutionDetails[float]: | ||
| return self._resolve(flag_key, default_value, float, evaluation_context) | ||
|
|
||
| def resolve_object_details( | ||
| self, | ||
| flag_key: str, | ||
| default_value: Union[Sequence[FlagValueType], Mapping[str, FlagValueType]], | ||
| evaluation_context: typing.Optional[EvaluationContext] = None, | ||
| ) -> FlagResolutionDetails[ | ||
| Union[Sequence[FlagValueType], Mapping[str, FlagValueType]] | ||
| ]: | ||
| return self._resolve(flag_key, default_value, None, evaluation_context) | ||
|
|
||
| @staticmethod | ||
| def _unwrap_value(value: typing.Any) -> typing.Any: | ||
| if isinstance(value, dict): | ||
| return {k: MixpanelProvider._unwrap_value(v) for k, v in value.items()} | ||
| if isinstance(value, list): | ||
| return [MixpanelProvider._unwrap_value(item) for item in value] | ||
| if isinstance(value, float) and value.is_integer(): | ||
| return int(value) | ||
| return value | ||
|
|
||
| @staticmethod | ||
| def _build_user_context( | ||
| evaluation_context: typing.Optional[EvaluationContext], | ||
| ) -> dict: | ||
| user_context: dict = {} | ||
| if evaluation_context is not None: | ||
| if evaluation_context.attributes: | ||
| for k, v in evaluation_context.attributes.items(): | ||
| user_context[k] = MixpanelProvider._unwrap_value(v) | ||
| if evaluation_context.targeting_key: | ||
| user_context["targetingKey"] = evaluation_context.targeting_key | ||
| return user_context | ||
|
|
||
| def _resolve( | ||
| self, | ||
| flag_key: str, | ||
| default_value: typing.Any, | ||
| expected_type: typing.Optional[type], | ||
| evaluation_context: typing.Optional[EvaluationContext] = None, | ||
| ) -> FlagResolutionDetails: | ||
| if not self._are_flags_ready(): | ||
| return FlagResolutionDetails( | ||
| value=default_value, | ||
| error_code=ErrorCode.PROVIDER_NOT_READY, | ||
| reason=Reason.ERROR, | ||
| ) | ||
|
|
||
| fallback = SelectedVariant(variant_value=default_value) | ||
| user_context = self._build_user_context(evaluation_context) | ||
| try: | ||
| result = self._flags_provider.get_variant(flag_key, fallback, user_context) | ||
| except Exception: | ||
| return FlagResolutionDetails( | ||
| value=default_value, | ||
| error_code=ErrorCode.GENERAL, | ||
| reason=Reason.ERROR, | ||
| ) | ||
|
|
||
| if result is fallback: | ||
| return FlagResolutionDetails( | ||
| value=default_value, | ||
| error_code=ErrorCode.FLAG_NOT_FOUND, | ||
| reason=Reason.ERROR, | ||
| ) | ||
|
|
||
| value = result.variant_value | ||
| variant_key = result.variant_key | ||
|
|
||
| if expected_type is None: | ||
| return FlagResolutionDetails( | ||
| value=value, variant=variant_key, reason=Reason.STATIC | ||
| ) | ||
|
|
||
| # In Python, bool is a subclass of int, so isinstance(True, int) | ||
| # returns True. Reject bools early when expecting numeric types. | ||
| if expected_type in (int, float) and isinstance(value, bool): | ||
| return FlagResolutionDetails( | ||
| value=default_value, | ||
| error_code=ErrorCode.TYPE_MISMATCH, | ||
msiebert marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| error_message=f"Expected {expected_type.__name__}, got {type(value).__name__}", | ||
| reason=Reason.ERROR, | ||
| ) | ||
|
|
||
| if expected_type is int and isinstance(value, float): | ||
| if math.isfinite(value) and value == math.floor(value): | ||
| return FlagResolutionDetails( | ||
| value=int(value), variant=variant_key, reason=Reason.STATIC | ||
| ) | ||
| return FlagResolutionDetails( | ||
| value=default_value, | ||
| error_code=ErrorCode.TYPE_MISMATCH, | ||
| error_message=f"Expected int, got float (value={value} is not a whole number)", | ||
| reason=Reason.ERROR, | ||
| ) | ||
|
|
||
| if expected_type is float and isinstance(value, (int, float)): | ||
| return FlagResolutionDetails( | ||
| value=float(value), variant=variant_key, reason=Reason.STATIC | ||
| ) | ||
|
|
||
| if not isinstance(value, expected_type): | ||
| return FlagResolutionDetails( | ||
| value=default_value, | ||
| error_code=ErrorCode.TYPE_MISMATCH, | ||
| error_message=f"Expected {expected_type.__name__}, got {type(value).__name__}", | ||
| reason=Reason.ERROR, | ||
| ) | ||
|
|
||
| return FlagResolutionDetails( | ||
| value=value, variant=variant_key, reason=Reason.STATIC | ||
| ) | ||
|
|
||
| def _are_flags_ready(self) -> bool: | ||
| if hasattr(self._flags_provider, "are_flags_ready"): | ||
| return self._flags_provider.are_flags_ready() | ||
| return True | ||
Uh oh!
There was an error while loading. Please reload this page.