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
7 changes: 4 additions & 3 deletions providers/openfeature-provider-flagd/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,7 @@ dependencies = [
"openfeature-sdk>=0.8.2",
"grpcio>=1.68.1",
"protobuf>=6.30.0,<7.0.0",
"mmh3>=5.0.0,<6.0.0",
"panzi-json-logic>=1.0.1",
"semver>=3,<4",
"flagd-evaluator", # Native Python bindings for flag evaluation
"pyyaml>=6.0.1",
"cachebox>=5.1.0,<6.0.0",
]
Expand Down Expand Up @@ -108,6 +106,9 @@ module = [
]
warn_unused_ignores = false

[tool.uv.sources]
flagd-evaluator = { path = "../../../flagd-evaluator/python", editable = true }

[project.scripts]
# workaround while UV doesn't support scripts directly in the pyproject.toml
# see: https://github.com/astral-sh/uv/issues/5903
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
from openfeature.provider.metadata import Metadata

from .config import CacheType, Config, ResolverType
from .resolvers import AbstractResolver, GrpcResolver, InProcessResolver
from .resolvers import AbstractResolver, GrpcResolver, InProcessResolver, WasmInProcessResolver
from .sync_metadata_hook import SyncMetadataHook

T = typing.TypeVar("T")
Expand Down Expand Up @@ -133,7 +133,7 @@ def setup_resolver(self) -> AbstractResolver:
self.config.resolver == ResolverType.IN_PROCESS
or self.config.resolver == ResolverType.FILE
):
return InProcessResolver(
return WasmInProcessResolver(
self.config,
self.emit_provider_ready_with_context,
self.emit_provider_error,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from .grpc import GrpcResolver
from .in_process import InProcessResolver
from .protocol import AbstractResolver
from .wasm_in_process import WasmInProcessResolver

__all__ = ["AbstractResolver", "GrpcResolver", "InProcessResolver"]
__all__ = ["AbstractResolver", "GrpcResolver", "InProcessResolver", "WasmInProcessResolver"]
Original file line number Diff line number Diff line change
Expand Up @@ -3,41 +3,41 @@
import time
import typing

from json_logic import builtins, jsonLogic
from json_logic.types import JsonValue

from flagd_evaluator import evaluate_targeting
from openfeature.evaluation_context import EvaluationContext
from openfeature.exception import ParseError

from .custom_ops import (
ends_with,
fractional,
sem_ver,
starts_with,
)

OPERATORS = {
**builtins.BUILTINS,
"fractional": fractional,
"starts_with": starts_with,
"ends_with": ends_with,
"sem_ver": sem_ver,
}


def targeting(
key: str,
targeting: dict,
evaluation_context: typing.Optional[EvaluationContext] = None,
) -> JsonValue:
) -> typing.Any:
"""
Evaluate targeting rules using the native flagd-evaluator.

This uses the Rust-based evaluator which includes all custom operators:
- fractional: A/B testing with consistent hashing
- sem_ver: Semantic version comparison
- starts_with: String prefix matching
- ends_with: String suffix matching
"""
if not isinstance(targeting, dict):
raise ParseError(f"Invalid 'targeting' value in flag: {targeting}")

# Build evaluation context matching flagd spec
json_logic_context: dict[str, typing.Any] = (
dict(evaluation_context.attributes) if evaluation_context else {}
)
json_logic_context["$flagd"] = {"flagKey": key, "timestamp": int(time.time())}
json_logic_context["targetingKey"] = (
evaluation_context.targeting_key if evaluation_context else None
)
return jsonLogic(targeting, json_logic_context, OPERATORS)

# Use native evaluator
result = evaluate_targeting(targeting, json_logic_context)

if not result["success"]:
raise ParseError(f"Targeting evaluation failed: {result.get('error')}")

return result["result"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
"""
WASM-based (Native Rust) In-Process Resolver.

This resolver uses the native flagd-evaluator (PyO3 bindings) for high-performance
flag evaluation. All evaluation logic and state management happens in Rust.
"""
import typing

from flagd_evaluator import FlagEvaluator
from openfeature.evaluation_context import EvaluationContext
from openfeature.event import ProviderEventDetails
from openfeature.exception import ErrorCode, FlagNotFoundError
from openfeature.flag_evaluation import FlagResolutionDetails, FlagValueType, Reason

from ..config import Config
from .process.connector import FlagStateConnector
from .process.connector.file_watcher import FileWatcher
from .process.connector.grpc_watcher import GrpcWatcher

T = typing.TypeVar("T")


class WasmInProcessResolver:
"""
In-process flag resolver using native Rust evaluator.

This resolver uses the flagd-evaluator Python bindings (PyO3/Rust) for
high-performance flag evaluation. All flag state and evaluation logic
is managed by the Rust implementation.
"""

def __init__(
self,
config: Config,
emit_provider_ready: typing.Callable[[ProviderEventDetails, dict], None],
emit_provider_error: typing.Callable[[ProviderEventDetails], None],
emit_provider_stale: typing.Callable[[ProviderEventDetails], None],
emit_provider_configuration_changed: typing.Callable[
[ProviderEventDetails], None
],
):
self.config = config
self.emit_configuration_changed = emit_provider_configuration_changed

# Create native evaluator (Rust-backed) with permissive validation mode
# to match Python InProcessResolver behavior
self.evaluator = FlagEvaluator(permissive=True)

# Setup connector (FileWatcher or GrpcWatcher)
# Connectors will call self.update_flags() when flags change
self.connector: FlagStateConnector = (
FileWatcher(
self.config,
self, # Pass self instead of FlagStore
emit_provider_ready,
emit_provider_error,
)
if self.config.offline_flag_source_path
else GrpcWatcher(
self.config,
self, # Pass self instead of FlagStore
emit_provider_ready,
emit_provider_error,
emit_provider_stale,
)
)

def initialize(self, evaluation_context: EvaluationContext) -> None:
"""Initialize the connector to start receiving flag updates."""
self.connector.initialize(evaluation_context)

def shutdown(self) -> None:
"""Shutdown the connector."""
self.connector.shutdown()

def update(self, flags_config: dict) -> None:
"""
Update the flag configuration in the Rust evaluator.

This is called by connectors (FileWatcher, GrpcWatcher) when
flag configuration changes. Method name matches FlagStore interface.

Args:
flags_config: Flag configuration in flagd format
"""
try:
self.evaluator.update_state(flags_config)
# Extract flag keys and metadata for event
flags = flags_config.get("flags", {})
metadata = flags_config.get("metadata", {})
# Emit configuration changed event with flag keys
self.emit_configuration_changed(
ProviderEventDetails(
flags_changed=list(flags.keys()),
metadata=metadata
)
)
except Exception as e:
# Log error but don't crash
import logging

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

It's standard practice to place all imports at the top of the file. This improves readability and avoids re-importing the module on every call to this method during an exception. Please move import logging to the top of the file. It's also a good practice to initialize the logger as a module-level constant, for example:

# At top of file
import logging

_logger = logging.getLogger("openfeature.contrib")

And then use _logger in your except block.

logger = logging.getLogger("openfeature.contrib")
logger.error(f"Failed to update flags in Rust evaluator: {e}")

def _resolve_flag(
self,
key: str,
default_value: T,
evaluation_context: typing.Optional[EvaluationContext],
type_validator: typing.Callable[[typing.Any], typing.Optional[T]],
) -> FlagResolutionDetails[T]:
"""
Generic flag resolution logic shared by all resolve methods.

Args:
key: Flag key
default_value: Default value to return if evaluation fails or type mismatches
evaluation_context: Evaluation context
type_validator: Function that validates and optionally converts the value.
Returns the validated value or None if invalid.
"""
context = self._build_context(evaluation_context)
try:
result = self.evaluator.evaluate(key, context)
value = result.get("value")

# Validate and convert value using the provided validator
validated_value = type_validator(value)
if validated_value is None:
validated_value = default_value

return FlagResolutionDetails(
value=validated_value,
variant=result.get("variant"),
reason=self._map_reason(result.get("reason")),
error_code=self._map_error_code(result.get("errorCode")),
flag_metadata=result.get("flagMetadata", {}),
)
except KeyError:
raise FlagNotFoundError(f"Flag with key {key} not found")
except RuntimeError as e:
raise FlagNotFoundError(f"Flag evaluation failed: {e}")

def resolve_boolean_details(
self,
key: str,
default_value: bool,
evaluation_context: typing.Optional[EvaluationContext] = None,
) -> FlagResolutionDetails[bool]:
"""Resolve a boolean flag using the native Rust evaluator."""
return self._resolve_flag(
key,
default_value,
evaluation_context,
lambda v: v if isinstance(v, bool) else None,
)

def resolve_string_details(
self,
key: str,
default_value: str,
evaluation_context: typing.Optional[EvaluationContext] = None,
) -> FlagResolutionDetails[str]:
"""Resolve a string flag using the native Rust evaluator."""
return self._resolve_flag(
key,
default_value,
evaluation_context,
lambda v: v if isinstance(v, str) else None,
)

def resolve_integer_details(
self,
key: str,
default_value: int,
evaluation_context: typing.Optional[EvaluationContext] = None,
) -> FlagResolutionDetails[int]:
"""Resolve an integer flag using the native Rust evaluator."""
return self._resolve_flag(
key,
default_value,
evaluation_context,
lambda v: v if isinstance(v, int) else None,
)

def resolve_float_details(
self,
key: str,
default_value: float,
evaluation_context: typing.Optional[EvaluationContext] = None,
) -> FlagResolutionDetails[float]:
"""Resolve a float flag using the native Rust evaluator."""
def validate_float(v: typing.Any) -> typing.Optional[float]:
# Allow int to float conversion
if isinstance(v, (int, float)):
return float(v)
return None

return self._resolve_flag(key, default_value, evaluation_context, validate_float)

def resolve_object_details(
self,
key: str,
default_value: typing.Union[
typing.Sequence[FlagValueType], typing.Mapping[str, FlagValueType]
],
evaluation_context: typing.Optional[EvaluationContext] = None,
) -> FlagResolutionDetails[
typing.Union[typing.Sequence[FlagValueType], typing.Mapping[str, FlagValueType]]
]:
"""Resolve an object flag using the native Rust evaluator."""
return self._resolve_flag(
key,
default_value,
evaluation_context,
lambda v: v if isinstance(v, (dict, list)) else None,
)

def _build_context(
self, evaluation_context: typing.Optional[EvaluationContext]
) -> dict:
"""
Build context dict for Rust evaluator from EvaluationContext.

The Rust evaluator expects a flat dict with all context attributes.
"""
if not evaluation_context:
return {}

context = dict(evaluation_context.attributes) if evaluation_context.attributes else {}

# Add targeting key if present
if evaluation_context.targeting_key:
context["targetingKey"] = evaluation_context.targeting_key

return context

def _map_reason(self, rust_reason: typing.Optional[str]) -> Reason:
"""Map Rust evaluator reason strings to OpenFeature Reason enum."""
if not rust_reason:
return Reason.UNKNOWN

reason_map = {
"STATIC": Reason.STATIC,
"DEFAULT": Reason.DEFAULT,
"TARGETING_MATCH": Reason.TARGETING_MATCH,
"DISABLED": Reason.DISABLED,
"ERROR": Reason.ERROR,
"FLAG_NOT_FOUND": Reason.ERROR,
"FALLBACK": Reason.ERROR, # Fallback to default when variant is null/undefined
}
return reason_map.get(rust_reason, Reason.UNKNOWN)

def _map_error_code(self, rust_error_code: typing.Optional[str]) -> typing.Optional[ErrorCode]:
"""Map Rust evaluator error code strings to OpenFeature ErrorCode enum."""
if not rust_error_code:
return None

error_code_map = {
"FLAG_NOT_FOUND": ErrorCode.FLAG_NOT_FOUND,
"PARSE_ERROR": ErrorCode.PARSE_ERROR,
"TYPE_MISMATCH": ErrorCode.TYPE_MISMATCH,
"GENERAL": ErrorCode.GENERAL,
}
return error_code_map.get(rust_error_code)
Loading
Loading