Repository: open-feature/python-sdk-contrib (language: python)

Issue:
Configure Renovate
[![Mend Renovate](https://app.renovatebot.com/images/banner.svg)](https://renovatebot.com)

Welcome to [Renovate](https://togithub.com/renovatebot/renovate)! This is an onboarding PR to help you understand and configure settings before regular Pull Requests begin.

🚦 To activate Renovate, merge this Pull Request. To disable Renovate, simply close this Pull Request unmerged.



---
### Detected Package Files

 * `.github/workflows/build.yml` (github-actions)
 * `.github/workflows/lint-pr.yml` (github-actions)
 * `hooks/openfeature-hooks-opentelemetry/pyproject.toml` (pep621)
 * `providers/openfeature-provider-flagd/pyproject.toml` (pep621)

### Configuration Summary

Based on the default config's presets, Renovate will:

  - Start dependency updates only once this onboarding PR is merged
  - Show all Merge Confidence badges for pull requests.
  - Enable Renovate Dependency Dashboard creation.
  - Use semantic commit type `fix` for dependencies and `chore` for all others if semantic commits are in use.
  - Ignore `node_modules`, `bower_components`, `vendor` and various test/tests directories.
  - Group known monorepo packages together.
  - Use curated list of recommended non-monorepo package groupings.
  - Apply crowd-sourced package replacement rules.
  - Apply crowd-sourced workarounds for known problems with packages.

🔡 Do you want to change how Renovate upgrades your dependencies? Add your custom config to `renovate.json` in this branch. Renovate will update the Pull Request description the next time it runs.

---

### What to Expect

With your current configuration, Renovate will create 1 Pull Request:

<details>
<summary>chore(deps): update bufbuild/buf-setup-action action to v1.29.0</summary>

  - Schedule: ["at any time"]
  - Branch name: `renovate/bufbuild-buf-setup-action-1.x`
  - Merge into: `main`
  - Upgrade [bufbuild/buf-setup-action](https://togithub.com/bufbuild/buf-setup-action) to `v1.29.0`


</details>


---

❓ Got questions? Check out Renovate's [Docs](https://docs.renovatebot.com/), particularly the Getting Started section.
If you need any further assistance then you can also [request help here](https://togithub.com/renovatebot/renovate/discussions).


---

This PR has been generated by [Mend Renovate](https://www.mend.io/free-developer-tools/renovate/). View repository job log [here](https://developer.mend.io/github/open-feature/python-sdk-contrib).

<!--renovate-config-hash:e80b4e42a3043bc12fa0640db4bac392d2bf770acf841360d7c8ceeeac2ec1a9-->



Discussion / hints:
## [Codecov](https://app.codecov.io/gh/open-feature/python-sdk-contrib/pull/1?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-feature) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Comparison is base [(`684a90a`)](https://app.codecov.io/gh/open-feature/python-sdk-contrib/commit/684a90ad81716726ef2c00a4a314691bd3bd713d?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-feature) 63.44% compared to head [(`c0fda4c`)](https://app.codecov.io/gh/open-feature/python-sdk-contrib/pull/1?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-feature) 63.44%.


<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff           @@
##             main       #1   +/-   ##
=======================================
  Coverage   63.44%   63.44%           
=======================================
  Files           8        8           
  Lines         238      238           
=======================================
  Hits          151      151           
  Misses         87       87           
```

| [Flag](https://app.codecov.io/gh/open-feature/python-sdk-contrib/pull/1/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-feature) | Coverage Δ | |
|---|---|---|
| [unittests](https://app.codecov.io/gh/open-feature/python-sdk-contrib/pull/1/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-feature) | `63.44% <ø> (ø)` | |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-feature#carryforward-flags-in-the-pull-request-comment) to find out more.


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/open-feature/python-sdk-contrib/pull/1?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-feature).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=open-feature).

### Renovate is disabled

Renovate is disabled because there is no Renovate configuration file. To enable Renovate, you can either (a) change this PR's title to get a new onboarding PR, and merge the new onboarding PR, or (b) create a Renovate config file, and commit that file to your base branch.

Fix patch:
diff --git a/.gitmodules b/.gitmodules
index 0a552e2f..a8bef85f 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,3 +1,6 @@
 [submodule "schemas"]
 	path = providers/openfeature-provider-flagd/schemas
 	url = https://github.com/open-feature/schemas
+[submodule "providers/openfeature-provider-flagd/test-harness"]
+	path = providers/openfeature-provider-flagd/test-harness
+	url = git@github.com:open-feature/flagd-testbed.git
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 508d68c5..ac517000 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -19,8 +19,13 @@ repos:
     rev: v1.9.0
     hooks:
       - id: mypy
+        args: [--python-version=3.8]
         additional_dependencies:
           - openfeature-sdk>=0.4.0
           - opentelemetry-api
           - types-protobuf
+          - types-PyYAML
+          - mmh3
+          - semver
+          - panzi-json-logic
         exclude: proto|tests
diff --git a/providers/openfeature-provider-flagd/README.md b/providers/openfeature-provider-flagd/README.md
index ea2a533c..aa63c55c 100644
--- a/providers/openfeature-provider-flagd/README.md
+++ b/providers/openfeature-provider-flagd/README.md
@@ -19,6 +19,19 @@ from openfeature.contrib.provider.flagd import FlagdProvider
 api.set_provider(FlagdProvider())
 ```
 
+To use in-process evaluation in offline mode with a file as source:
+
+```python
+from openfeature import api
+from openfeature.contrib.provider.flagd import FlagdProvider
+from openfeature.contrib.provider.flagd.config import ResolverType
+
+api.set_provider(FlagdProvider(
+    resolver_type=ResolverType.IN_PROCESS,
+    offline_flag_source_path="my-flag.json",
+))
+```
+
 ### Configuration options
 
 The default options can be defined in the FlagdProvider constructor.
diff --git a/providers/openfeature-provider-flagd/pyproject.toml b/providers/openfeature-provider-flagd/pyproject.toml
index cd435bc7..6fe406b6 100644
--- a/providers/openfeature-provider-flagd/pyproject.toml
+++ b/providers/openfeature-provider-flagd/pyproject.toml
@@ -20,6 +20,10 @@ dependencies = [
   "openfeature-sdk>=0.4.0",
   "grpcio>=1.60.0",
   "protobuf>=4.25.2",
+  "mmh3>=4.1.0",
+  "panzi-json-logic>=1.0.1",
+  "semver>=3,<4",
+  "pyyaml>=6.0.1",
 ]
 requires-python = ">=3.8"
 
@@ -32,6 +36,7 @@ Homepage = "https://github.com/open-feature/python-sdk-contrib"
 dependencies = [
   "coverage[toml]>=6.5",
   "pytest",
+  "pytest-bdd",
 ]
 post-install-commands = [
   "./scripts/gen_protos.sh"
@@ -42,6 +47,7 @@ test = "pytest {args:tests}"
 test-cov = "coverage run -m pytest {args:tests}"
 cov-report = [
   "coverage xml",
+  "coverage html",
 ]
 cov = [
   "test-cov",
@@ -61,4 +67,5 @@ packages = ["src/openfeature"]
 omit = [
   # exclude generated files
   "src/openfeature/contrib/provider/flagd/proto/*",
+  "tests/**",
 ]
diff --git a/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/config.py b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/config.py
index e2db98a5..a95c3153 100644
--- a/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/config.py
+++ b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/config.py
@@ -1,5 +1,6 @@
 import os
 import typing
+from enum import Enum
 
 T = typing.TypeVar("T")
 
@@ -17,13 +18,21 @@ def env_or_default(
     return val if cast is None else cast(val)
 
 
+class ResolverType(Enum):
+    GRPC = "grpc"
+    IN_PROCESS = "in-process"
+
+
 class Config:
-    def __init__(
+    def __init__(  # noqa: PLR0913
         self,
         host: typing.Optional[str] = None,
         port: typing.Optional[int] = None,
         tls: typing.Optional[bool] = None,
         timeout: typing.Optional[int] = None,
+        resolver_type: typing.Optional[ResolverType] = None,
+        offline_flag_source_path: typing.Optional[str] = None,
+        offline_poll_interval_seconds: typing.Optional[float] = None,
     ):
         self.host = env_or_default("FLAGD_HOST", "localhost") if host is None else host
         self.port = (
@@ -33,3 +42,18 @@ def __init__(
             env_or_default("FLAGD_TLS", False, cast=str_to_bool) if tls is None else tls
         )
         self.timeout = 5 if timeout is None else timeout
+        self.resolver_type = (
+            ResolverType(env_or_default("FLAGD_RESOLVER_TYPE", "grpc"))
+            if resolver_type is None
+            else resolver_type
+        )
+        self.offline_flag_source_path = (
+            env_or_default("FLAGD_OFFLINE_FLAG_SOURCE_PATH", None)
+            if offline_flag_source_path is None
+            else offline_flag_source_path
+        )
+        self.offline_poll_interval_seconds = (
+            float(env_or_default("FLAGD_OFFLINE_POLL_INTERVAL_SECONDS", 1.0))
+            if offline_poll_interval_seconds is None
+            else offline_poll_interval_seconds
+        )
diff --git a/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/provider.py b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/provider.py
index ea91a1d1..76307475 100644
--- a/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/provider.py
+++ b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/provider.py
@@ -23,24 +23,13 @@
 
 import typing
 
-import grpc
-from google.protobuf.struct_pb2 import Struct
-
 from openfeature.evaluation_context import EvaluationContext
-from openfeature.exception import (
-    FlagNotFoundError,
-    GeneralError,
-    InvalidContextError,
-    ParseError,
-    TypeMismatchError,
-)
 from openfeature.flag_evaluation import FlagResolutionDetails
 from openfeature.provider.metadata import Metadata
 from openfeature.provider.provider import AbstractProvider
 
-from .config import Config
-from .flag_type import FlagType
-from .proto.schema.v1 import schema_pb2, schema_pb2_grpc
+from .config import Config, ResolverType
+from .resolvers import AbstractResolver, GrpcResolver, InProcessResolver
 
 T = typing.TypeVar("T")
 
@@ -48,12 +37,15 @@
 class FlagdProvider(AbstractProvider):
     """Flagd OpenFeature Provider"""
 
-    def __init__(
+    def __init__(  # noqa: PLR0913
         self,
         host: typing.Optional[str] = None,
         port: typing.Optional[int] = None,
         tls: typing.Optional[bool] = None,
         timeout: typing.Optional[int] = None,
+        resolver_type: typing.Optional[ResolverType] = None,
+        offline_flag_source_path: typing.Optional[str] = None,
+        offline_poll_interval_seconds: typing.Optional[float] = None,
     ):
         """
         Create an instance of the FlagdProvider
@@ -68,14 +60,26 @@ def __init__(
             port=port,
             tls=tls,
             timeout=timeout,
+            resolver_type=resolver_type,
+            offline_flag_source_path=offline_flag_source_path,
+            offline_poll_interval_seconds=offline_poll_interval_seconds,
         )
 
-        channel_factory = grpc.secure_channel if tls else grpc.insecure_channel
-        self.channel = channel_factory(f"{self.config.host}:{self.config.port}")
-        self.stub = schema_pb2_grpc.ServiceStub(self.channel)
+        self.resolver = self.setup_resolver()
+
+    def setup_resolver(self) -> AbstractResolver:
+        if self.config.resolver_type == ResolverType.GRPC:
+            return GrpcResolver(self.config)
+        elif self.config.resolver_type == ResolverType.IN_PROCESS:
+            return InProcessResolver(self.config, self)
+        else:
+            raise ValueError(
+                f"`resolver_type` parameter invalid: {self.config.resolver_type}"
+            )
 
     def shutdown(self) -> None:
-        self.channel.close()
+        if self.resolver:
+            self.resolver.shutdown()
 
     def get_metadata(self) -> Metadata:
         """Returns provider metadata"""
@@ -87,7 +91,9 @@ def resolve_boolean_details(
         default_value: bool,
         evaluation_context: typing.Optional[EvaluationContext] = None,
     ) -> FlagResolutionDetails[bool]:
-        return self._resolve(key, FlagType.BOOLEAN, default_value, evaluation_context)
+        return self.resolver.resolve_boolean_details(
+            key, default_value, evaluation_context
+        )
 
     def resolve_string_details(
         self,
@@ -95,7 +101,9 @@ def resolve_string_details(
         default_value: str,
         evaluation_context: typing.Optional[EvaluationContext] = None,
     ) -> FlagResolutionDetails[str]:
-        return self._resolve(key, FlagType.STRING, default_value, evaluation_context)
+        return self.resolver.resolve_string_details(
+            key, default_value, evaluation_context
+        )
 
     def resolve_float_details(
         self,
@@ -103,7 +111,9 @@ def resolve_float_details(
         default_value: float,
         evaluation_context: typing.Optional[EvaluationContext] = None,
     ) -> FlagResolutionDetails[float]:
-        return self._resolve(key, FlagType.FLOAT, default_value, evaluation_context)
+        return self.resolver.resolve_float_details(
+            key, default_value, evaluation_context
+        )
 
     def resolve_integer_details(
         self,
@@ -111,7 +121,9 @@ def resolve_integer_details(
         default_value: int,
         evaluation_context: typing.Optional[EvaluationContext] = None,
     ) -> FlagResolutionDetails[int]:
-        return self._resolve(key, FlagType.INTEGER, default_value, evaluation_context)
+        return self.resolver.resolve_integer_details(
+            key, default_value, evaluation_context
+        )
 
     def resolve_object_details(
         self,
@@ -119,76 +131,6 @@ def resolve_object_details(
         default_value: typing.Union[dict, list],
         evaluation_context: typing.Optional[EvaluationContext] = None,
     ) -> FlagResolutionDetails[typing.Union[dict, list]]:
-        return self._resolve(key, FlagType.OBJECT, default_value, evaluation_context)
-
-    def _resolve(
-        self,
-        flag_key: str,
-        flag_type: FlagType,
-        default_value: T,
-        evaluation_context: typing.Optional[EvaluationContext],
-    ) -> FlagResolutionDetails[T]:
-        context = self._convert_context(evaluation_context)
-        call_args = {"timeout": self.config.timeout}
-        try:
-            if flag_type == FlagType.BOOLEAN:
-                request = schema_pb2.ResolveBooleanRequest(  # type:ignore[attr-defined]
-                    flag_key=flag_key, context=context
-                )
-                response = self.stub.ResolveBoolean(request, **call_args)
-            elif flag_type == FlagType.STRING:
-                request = schema_pb2.ResolveStringRequest(  # type:ignore[attr-defined]
-                    flag_key=flag_key, context=context
-                )
-                response = self.stub.ResolveString(request, **call_args)
-            elif flag_type == FlagType.OBJECT:
-                request = schema_pb2.ResolveObjectRequest(  # type:ignore[attr-defined]
-                    flag_key=flag_key, context=context
-                )
-                response = self.stub.ResolveObject(request, **call_args)
-            elif flag_type == FlagType.FLOAT:
-                request = schema_pb2.ResolveFloatRequest(  # type:ignore[attr-defined]
-                    flag_key=flag_key, context=context
-                )
-                response = self.stub.ResolveFloat(request, **call_args)
-            elif flag_type == FlagType.INTEGER:
-                request = schema_pb2.ResolveIntRequest(  # type:ignore[attr-defined]
-                    flag_key=flag_key, context=context
-                )
-                response = self.stub.ResolveInt(request, **call_args)
-            else:
-                raise ValueError(f"Unknown flag type: {flag_type}")
-
-        except grpc.RpcError as e:
-            code = e.code()
-            message = f"received grpc status code {code}"
-
-            if code == grpc.StatusCode.NOT_FOUND:
-                raise FlagNotFoundError(message) from e
-            elif code == grpc.StatusCode.INVALID_ARGUMENT:
-                raise TypeMismatchError(message) from e
-            elif code == grpc.StatusCode.DATA_LOSS:
-                raise ParseError(message) from e
-            raise GeneralError(message) from e
-
-        # Got a valid flag and valid type. Return it.
-        return FlagResolutionDetails(
-            value=response.value,
-            reason=response.reason,
-            variant=response.variant,
+        return self.resolver.resolve_object_details(
+            key, default_value, evaluation_context
         )
-
-    def _convert_context(
-        self, evaluation_context: typing.Optional[EvaluationContext]
-    ) -> Struct:
-        s = Struct()
-        if evaluation_context:
-            try:
-                s["targetingKey"] = evaluation_context.targeting_key
-                s.update(evaluation_context.attributes)
-            except ValueError as exc:
-                message = (
-                    "could not serialize evaluation context to google.protobuf.Struct"
-                )
-                raise InvalidContextError(message) from exc
-        return s
diff --git a/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/__init__.py b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/__init__.py
new file mode 100644
index 00000000..53e17938
--- /dev/null
+++ b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/__init__.py
@@ -0,0 +1,51 @@
+import typing
+
+from typing_extensions import Protocol
+
+from openfeature.evaluation_context import EvaluationContext
+from openfeature.flag_evaluation import FlagResolutionDetails
+
+from .grpc import GrpcResolver
+from .in_process import InProcessResolver
+
+
+class AbstractResolver(Protocol):
+    def shutdown(self) -> None: ...
+
+    def resolve_boolean_details(
+        self,
+        key: str,
+        default_value: bool,
+        evaluation_context: typing.Optional[EvaluationContext] = None,
+    ) -> FlagResolutionDetails[bool]: ...
+
+    def resolve_string_details(
+        self,
+        key: str,
+        default_value: str,
+        evaluation_context: typing.Optional[EvaluationContext] = None,
+    ) -> FlagResolutionDetails[str]: ...
+
+    def resolve_float_details(
+        self,
+        key: str,
+        default_value: float,
+        evaluation_context: typing.Optional[EvaluationContext] = None,
+    ) -> FlagResolutionDetails[float]: ...
+
+    def resolve_integer_details(
+        self,
+        key: str,
+        default_value: int,
+        evaluation_context: typing.Optional[EvaluationContext] = None,
+    ) -> FlagResolutionDetails[int]: ...
+
+    def resolve_object_details(
+        self,
+        key: str,
+        default_value: typing.Union[dict, list],
+        evaluation_context: typing.Optional[EvaluationContext] = None,
+    ) -> FlagResolutionDetails[typing.Union[dict, list]]: ...
+
+
+__all__ = ["AbstractResolver", "GrpcResolver", "InProcessResolver"]
diff --git a/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/grpc.py b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/grpc.py
new file mode 100644
index 00000000..caab101a
--- /dev/null
+++ b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/grpc.py
@@ -0,0 +1,145 @@
+import typing
+
+import grpc
+from google.protobuf.struct_pb2 import Struct
+
+from openfeature.evaluation_context import EvaluationContext
+from openfeature.exception import (
+    FlagNotFoundError,
+    GeneralError,
+    InvalidContextError,
+    ParseError,
+    TypeMismatchError,
+)
+from openfeature.flag_evaluation import FlagResolutionDetails
+
+from ..config import Config
+from ..flag_type import FlagType
+from ..proto.schema.v1 import schema_pb2, schema_pb2_grpc
+
+T = typing.TypeVar("T")
+
+
+class GrpcResolver:
+    def __init__(self, config: Config):
+        self.config = config
+        channel_factory = (
+            grpc.secure_channel if self.config.tls else grpc.insecure_channel
+        )
+        self.channel = channel_factory(f"{self.config.host}:{self.config.port}")
+        self.stub = schema_pb2_grpc.ServiceStub(self.channel)
+
+    def shutdown(self) -> None:
+        self.channel.close()
+
+    def resolve_boolean_details(
+        self,
+        key: str,
+        default_value: bool,
+        evaluation_context: typing.Optional[EvaluationContext] = None,
+    ) -> FlagResolutionDetails[bool]:
+        return self._resolve(key, FlagType.BOOLEAN, default_value, evaluation_context)
+
+    def resolve_string_details(
+        self,
+        key: str,
+        default_value: str,
+        evaluation_context: typing.Optional[EvaluationContext] = None,
+    ) -> FlagResolutionDetails[str]:
+        return self._resolve(key, FlagType.STRING, default_value, evaluation_context)
+
+    def resolve_float_details(
+        self,
+        key: str,
+        default_value: float,
+        evaluation_context: typing.Optional[EvaluationContext] = None,
+    ) -> FlagResolutionDetails[float]:
+        return self._resolve(key, FlagType.FLOAT, default_value, evaluation_context)
+
+    def resolve_integer_details(
+        self,
+        key: str,
+        default_value: int,
+        evaluation_context: typing.Optional[EvaluationContext] = None,
+    ) -> FlagResolutionDetails[int]:
+        return self._resolve(key, FlagType.INTEGER, default_value, evaluation_context)
+
+    def resolve_object_details(
+        self,
+        key: str,
+        default_value: typing.Union[dict, list],
+        evaluation_context: typing.Optional[EvaluationContext] = None,
+    ) -> FlagResolutionDetails[typing.Union[dict, list]]:
+        return self._resolve(key, FlagType.OBJECT, default_value, evaluation_context)
+
+    def _resolve(
+        self,
+        flag_key: str,
+        flag_type: FlagType,
+        default_value: T,
+        evaluation_context: typing.Optional[EvaluationContext],
+    ) -> FlagResolutionDetails[T]:
+        context = self._convert_context(evaluation_context)
+        call_args = {"timeout": self.config.timeout}
+        try:
+            if flag_type == FlagType.BOOLEAN:
+                request = schema_pb2.ResolveBooleanRequest(  # type:ignore[attr-defined]
+                    flag_key=flag_key, context=context
+                )
+                response = self.stub.ResolveBoolean(request, **call_args)
+            elif flag_type == FlagType.STRING:
+                request = schema_pb2.ResolveStringRequest(  # type:ignore[attr-defined]
+                    flag_key=flag_key, context=context
+                )
+                response = self.stub.ResolveString(request, **call_args)
+            elif flag_type == FlagType.OBJECT:
+                request = schema_pb2.ResolveObjectRequest(  # type:ignore[attr-defined]
+                    flag_key=flag_key, context=context
+                )
+                response = self.stub.ResolveObject(request, **call_args)
+            elif flag_type == FlagType.FLOAT:
+                request = schema_pb2.ResolveFloatRequest(  # type:ignore[attr-defined]
+                    flag_key=flag_key, context=context
+                )
+                response = self.stub.ResolveFloat(request, **call_args)
+            elif flag_type == FlagType.INTEGER:
+                request = schema_pb2.ResolveIntRequest(  # type:ignore[attr-defined]
+                    flag_key=flag_key, context=context
+                )
+                response = self.stub.ResolveInt(request, **call_args)
+            else:
+                raise ValueError(f"Unknown flag type: {flag_type}")
+
+        except grpc.RpcError as e:
+            code = e.code()
+            message = f"received grpc status code {code}"
+
+            if code == grpc.StatusCode.NOT_FOUND:
+                raise FlagNotFoundError(message) from e
+            elif code == grpc.StatusCode.INVALID_ARGUMENT:
+                raise TypeMismatchError(message) from e
+            elif code == grpc.StatusCode.DATA_LOSS:
+                raise ParseError(message) from e
+            raise GeneralError(message) from e
+
+        # Got a valid flag and valid type. Return it.
+        return FlagResolutionDetails(
+            value=response.value,
+            reason=response.reason,
+            variant=response.variant,
+        )
+
+    def _convert_context(
+        self, evaluation_context: typing.Optional[EvaluationContext]
+    ) -> Struct:
+        s = Struct()
+        if evaluation_context:
+            try:
+                s["targetingKey"] = evaluation_context.targeting_key
+                s.update(evaluation_context.attributes)
+            except ValueError as exc:
+                message = (
+                    "could not serialize evaluation context to google.protobuf.Struct"
+                )
+                raise InvalidContextError(message) from exc
+        return s
diff --git a/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/in_process.py b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/in_process.py
new file mode 100644
index 00000000..907a62d6
--- /dev/null
+++ b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/in_process.py
@@ -0,0 +1,122 @@
+import time
+import typing
+
+from json_logic import builtins, jsonLogic  # type: ignore[import-untyped]
+
+from openfeature.evaluation_context import EvaluationContext
+from openfeature.exception import FlagNotFoundError, ParseError
+from openfeature.flag_evaluation import FlagResolutionDetails, Reason
+from openfeature.provider.provider import AbstractProvider
+
+from ..config import Config
+from .process.custom_ops import ends_with, fractional, sem_ver, starts_with
+from .process.file_watcher import FileWatcherFlagStore
+
+T = typing.TypeVar("T")
+
+
+class InProcessResolver:
+    OPERATORS: typing.ClassVar[dict] = {
+        **builtins.BUILTINS,
+        "fractional": fractional,
+        "starts_with": starts_with,
+        "ends_with": ends_with,
+        "sem_ver": sem_ver,
+    }
+
+    def __init__(self, config: Config, provider: AbstractProvider):
+        self.config = config
+        self.provider = provider
+        if not self.config.offline_flag_source_path:
+            raise ValueError(
+                "offline_flag_source_path must be provided when using in-process resolver"
+            )
+        self.flag_store = FileWatcherFlagStore(
+            self.config.offline_flag_source_path,
+            self.provider,
+            self.config.offline_poll_interval_seconds,
+        )
+
+    def shutdown(self) -> None:
+        self.flag_store.shutdown()
+
+    def resolve_boolean_details(
+        self,
+        key: str,
+        default_value: bool,
+        evaluation_context: typing.Optional[EvaluationContext] = None,
+    ) -> FlagResolutionDetails[bool]:
+        return self._resolve(key, default_value, evaluation_context)
+
+    def resolve_string_details(
+        self,
+        key: str,
+        default_value: str,
+        evaluation_context: typing.Optional[EvaluationContext] = None,
+    ) -> FlagResolutionDetails[str]:
+        return self._resolve(key, default_value, evaluation_context)
+
+    def resolve_float_details(
+        self,
+        key: str,
+        default_value: float,
+        evaluation_context: typing.Optional[EvaluationContext] = None,
+    ) -> FlagResolutionDetails[float]:
+        return self._resolve(key, default_value, evaluation_context)
+
+    def resolve_integer_details(
+        self,
+        key: str,
+        default_value: int,
+        evaluation_context: typing.Optional[EvaluationContext] = None,
+    ) -> FlagResolutionDetails[int]:
+        return self._resolve(key, default_value, evaluation_context)
+
+    def resolve_object_details(
+        self,
+        key: str,
+        default_value: typing.Union[dict, list],
+        evaluation_context: typing.Optional[EvaluationContext] = None,
+    ) -> FlagResolutionDetails[typing.Union[dict, list]]:
+        return self._resolve(key, default_value, evaluation_context)
+
+    def _resolve(
+        self,
+        key: str,
+        default_value: T,
+        evaluation_context: typing.Optional[EvaluationContext] = None,
+    ) -> FlagResolutionDetails[T]:
+        flag = self.flag_store.get_flag(key)
+        if not flag:
+            raise FlagNotFoundError(f"Flag with key {key} not present in flag store.")
+
+        if flag.state == "DISABLED":
+            return FlagResolutionDetails(default_value, reason=Reason.DISABLED)
+
+        if not flag.targeting:
+            variant, value = flag.default
+            return FlagResolutionDetails(value, variant=variant, reason=Reason.STATIC)
+
+        json_logic_context = 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
+        )
+        variant = jsonLogic(flag.targeting, json_logic_context, self.OPERATORS)
+        if variant is None:
+            variant, value = flag.default
+            return FlagResolutionDetails(value, variant=variant, reason=Reason.DEFAULT)
+        if not isinstance(variant, (str, bool)):
+            raise ParseError(
+                "Parsed JSONLogic targeting did not return a string or bool"
+            )
+
+        variant, value = flag.get_variant(variant)
+        if not value:
+            raise ParseError(f"Resolved variant {variant} not in variants config.")
+
+        return FlagResolutionDetails(
+            value,
+            variant=variant,
+            reason=Reason.TARGETING_MATCH,
+        )
diff --git a/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/custom_ops.py b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/custom_ops.py
new file mode 100644
index 00000000..17763615
--- /dev/null
+++ b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/custom_ops.py
@@ -0,0 +1,126 @@
+import logging
+import typing
+
+import mmh3
+import semver
+
+JsonPrimitive = typing.Union[str, bool, float, int]
+JsonLogicArg = typing.Union[JsonPrimitive, typing.Sequence[JsonPrimitive]]
+
+logger = logging.getLogger("openfeature.contrib")
+
+
+def fractional(data: dict, *args: JsonLogicArg) -> typing.Optional[str]:
+    if not args:
+        logger.error("No arguments provided to fractional operator.")
+        return None
+
+    bucket_by = None
+    if isinstance(args[0], str):
+        bucket_by = args[0]
+        args = args[1:]
+    else:
+        seed = data.get("$flagd", {}).get("flagKey", "")
+        targeting_key = data.get("targetingKey")
+        if not targeting_key:
+            logger.error("No targetingKey provided for fractional shorthand syntax.")
+            return None
+        bucket_by = seed + targeting_key
+
+    if not bucket_by:
+        logger.error("No hashKey value resolved")
+        return None
+
+    hash_ratio = abs(mmh3.hash(bucket_by)) / (2**31 - 1)
+    bucket = int(hash_ratio * 100)
+
+    for arg in args:
+        if (
+            not isinstance(arg, (tuple, list))
+            or len(arg) != 2
+            or not isinstance(arg[0], str)
+            or not isinstance(arg[1], int)
+        ):
+            logger.error("Fractional variant weights must be (str, int) tuple")
+            return None
+    variant_weights: typing.Tuple[typing.Tuple[str, int]] = args  # type: ignore[assignment]
+
+    range_end = 0
+    for variant, weight in variant_weights:
+        range_end += weight
+        if bucket < range_end:
+            return variant
+
+    return None
+
+
+def starts_with(data: dict, *args: JsonLogicArg) -> typing.Optional[bool]:
+    def f(s1: str, s2: str) -> bool:
+        return s1.startswith(s2)
+
+    return string_comp(f, data, *args)
+
+
+def ends_with(data: dict, *args: JsonLogicArg) -> typing.Optional[bool]:
+    def f(s1: str, s2: str) -> bool:
+        return s1.endswith(s2)
+
+    return string_comp(f, data, *args)
+
+
+def string_comp(
+    comparator: typing.Callable[[str, str], bool], data: dict, *args: JsonLogicArg
+) -> typing.Optional[bool]:
+    if not args:
+        logger.error("No arguments provided to string_comp operator.")
+        return None
+    if len(args) != 2:
+        logger.error("Exactly 2 args expected for string_comp operator.")
+        return None
+    arg1, arg2 = args
+    if not isinstance(arg1, str):
+        logger.debug(f"incorrect argument for first argument, expected string: {arg1}")
+        return False
+    if not isinstance(arg2, str):
+        logger.debug(f"incorrect argument for second argument, expected string: {arg2}")
+        return False
+
+    return comparator(arg1, arg2)
+
+
+def sem_ver(data: dict, *args: JsonLogicArg) -> typing.Optional[bool]:  # noqa: C901
+    if not args:
+        logger.error("No arguments provided to sem_ver operator.")
+        return None
+    if len(args) != 3:
+        logger.error("Exactly 3 args expected for sem_ver operator.")
+        return None
+
+    arg1, op, arg2 = args
+
+    try:
+        v1 = semver.Version.parse(str(arg1))
+        v2 = semver.Version.parse(str(arg2))
+    except ValueError as e:
+        logger.exception(e)
+        return None
+
+    if op == "=":
+        return v1 == v2
+    elif op == "!=":
+        return v1 != v2
+    elif op == "<":
+        return v1 < v2
+    elif op == "<=":
+        return v1 <= v2
+    elif op == ">":
+        return v1 > v2
+    elif op == ">=":
+        return v1 >= v2
+    elif op == "^":
+        return v1.major == v2.major
+    elif op == "~":
+        return v1.major == v2.major and v1.minor == v2.minor
+    else:
+        logger.error(f"Op not supported by sem_ver: {op}")
+        return None
diff --git a/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/file_watcher.py b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/file_watcher.py
new file mode 100644
index 00000000..0918981f
--- /dev/null
+++ b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/file_watcher.py
@@ -0,0 +1,89 @@
+import json
+import logging
+import os
+import re
+import threading
+import time
+import typing
+
+import yaml
+
+from openfeature.event import ProviderEventDetails
+from openfeature.exception import ParseError
+from openfeature.provider.provider import AbstractProvider
+
+from .flags import Flag
+
+logger = logging.getLogger("openfeature.contrib")
+
+
+class FileWatcherFlagStore:
+    def __init__(
+        self,
+        file_path: str,
+        provider: AbstractProvider,
+        poll_interval_seconds: float = 1.0,
+    ):
+        self.file_path = file_path
+        self.provider = provider
+        self.poll_interval_seconds = poll_interval_seconds
+
+        self.last_modified = 0.0
+        self.flag_data: typing.Mapping[str, Flag] = {}
+        self.load_data()
+        self.thread = threading.Thread(target=self.refresh_file, daemon=True)
+        self.thread.start()
+
+    def shutdown(self) -> None:
+        pass
+
+    def get_flag(self, key: str) -> typing.Optional[Flag]:
+        return self.flag_data.get(key)
+
+    def refresh_file(self) -> None:
+        while True:
+            time.sleep(self.poll_interval_seconds)
+            logger.debug("checking for new flag store contents from file")
+            last_modified = os.path.getmtime(self.file_path)
+            if last_modified > self.last_modified:
+                self.load_data(last_modified)
+
+    def load_data(self, modified_time: typing.Optional[float] = None) -> None:
+        try:
+            with open(self.file_path) as file:
+                if self.file_path.endswith(".yaml"):
+                    data = yaml.safe_load(file)
+                else:
+                    data = json.load(file)
+
+                self.flag_data = self.parse_flags(data)
+                logger.debug(f"{self.flag_data=}")
+                self.provider.emit_provider_configuration_changed(
+                    ProviderEventDetails(flags_changed=list(self.flag_data.keys()))
+                )
+            self.last_modified = modified_time or os.path.getmtime(self.file_path)
+        except FileNotFoundError:
+            logger.exception("Provided file path not valid")
+        except json.JSONDecodeError:
+            logger.exception("Could not parse JSON flag data from file")
+        except yaml.error.YAMLError:
+            logger.exception("Could not parse YAML flag data from file")
+        except ParseError:
+            logger.exception("Could not parse flag data using flagd syntax")
+        except Exception:
+            logger.exception("Could not read flags from file")
+
+    def parse_flags(self, flags_data: dict) -> dict:
+        flags = flags_data.get("flags", {})
+        evaluators: typing.Optional[dict] = flags_data.get("$evaluators")
+        if evaluators:
+            transposed = json.dumps(flags)
+            for name, rule in evaluators.items():
+                transposed = re.sub(
+                    rf"{{\s*\"\$ref\":\s*\"{name}\"\s*}}", json.dumps(rule), transposed
+                )
+            flags = json.loads(transposed)
+
+        if not isinstance(flags, dict):
+            raise ParseError("`flags` key of configuration must be a dictionary")
+        return {key: Flag.from_dict(key, data) for key, data in flags.items()}
diff --git a/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/flags.py b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/flags.py
new file mode 100644
index 00000000..0354ac42
--- /dev/null
+++ b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/flags.py
@@ -0,0 +1,51 @@
+import typing
+from dataclasses import dataclass
+
+from openfeature.exception import ParseError
+
+
+@dataclass
+class Flag:
+    key: str
+    state: str
+    variants: typing.Mapping[str, typing.Any]
+    default_variant: typing.Union[bool, str]
+    targeting: typing.Optional[dict] = None
+
+    def __post_init__(self) -> None:
+        if not self.state or not isinstance(self.state, str):
+            raise ParseError("Incorrect 'state' value provided in flag config")
+
+        if not self.variants or not isinstance(self.variants, dict):
+            raise ParseError("Incorrect 'variants' value provided in flag config")
+
+        if not self.default_variant or not isinstance(
+            self.default_variant, (str, bool)
+        ):
+            raise ParseError("Incorrect 'defaultVariant' value provided in flag config")
+
+        if self.targeting and not isinstance(self.targeting, dict):
+            raise ParseError("Incorrect 'targeting' value provided in flag config")
+
+        if self.default_variant not in self.variants:
+            raise ParseError("Default variant does not match set of variants")
+
+    @classmethod
+    def from_dict(cls, key: str, data: dict) -> "Flag":
+        data["default_variant"] = data["defaultVariant"]
+        del data["defaultVariant"]
+        flag = cls(key=key, **data)
+
+        return flag
+
+    @property
+    def default(self) -> typing.Tuple[str, typing.Any]:
+        return self.get_variant(self.default_variant)
+
+    def get_variant(
+        self, variant_key: typing.Union[str, bool]
+    ) -> typing.Tuple[str, typing.Any]:
+        if isinstance(variant_key, bool):
+            variant_key = str(variant_key).lower()
+
+        return variant_key, self.variants.get(variant_key)


Test patch:
diff --git a/providers/openfeature-provider-flagd/test-harness b/providers/openfeature-provider-flagd/test-harness
new file mode 160000
index 00000000..6197b3d9
--- /dev/null
+++ b/providers/openfeature-provider-flagd/test-harness
@@ -0,0 +1,1 @@
+Subproject commit 6197b3d956d358bf662e5b8e0aebdc4800480f6b
diff --git a/providers/openfeature-provider-flagd/tests/conftest.py b/providers/openfeature-provider-flagd/tests/conftest.py
index cdf4bf59..287f5240 100644
--- a/providers/openfeature-provider-flagd/tests/conftest.py
+++ b/providers/openfeature-provider-flagd/tests/conftest.py
@@ -1,3 +1,5 @@
+import os
+
 import pytest
 
 from openfeature import api
@@ -8,3 +10,12 @@
 def flagd_provider_client():
     api.set_provider(FlagdProvider())
     return api.get_client()
+
+
+def setup_flag_file(base_dir: str, flag_file: str) -> str:
+    with open(f"test-harness/flags/{flag_file}") as src_file:
+        contents = src_file.read()
+    dst_path = os.path.join(base_dir, flag_file)
+    with open(dst_path, "w") as dst_file:
+        dst_file.write(contents)
+    return dst_path
diff --git a/providers/openfeature-provider-flagd/tests/e2e/conftest.py b/providers/openfeature-provider-flagd/tests/e2e/conftest.py
new file mode 100644
index 00000000..243f5724
--- /dev/null
+++ b/providers/openfeature-provider-flagd/tests/e2e/conftest.py
@@ -0,0 +1,208 @@
+import typing
+
+import pytest
+from pytest_bdd import given, parsers, then, when
+from tests.e2e.parsers import to_bool
+
+from openfeature import api
+from openfeature.client import OpenFeatureClient
+from openfeature.contrib.provider.flagd import FlagdProvider
+from openfeature.contrib.provider.flagd.config import ResolverType
+from openfeature.evaluation_context import EvaluationContext
+
+JsonPrimitive = typing.Union[str, bool, float, int]
+
+
+@pytest.fixture
+def evaluation_context() -> EvaluationContext:
+    return EvaluationContext()
+
+
+@given("a flagd provider is set", target_fixture="client")
+def setup_provider(flag_file) -> OpenFeatureClient:
+    api.set_provider(
+        FlagdProvider(
+            resolver_type=ResolverType.IN_PROCESS,
+            offline_flag_source_path=flag_file,
+            offline_poll_interval_seconds=0.1,
+        )
+    )
+    return api.get_client()
+
+
+@when(
+    parsers.cfparse(
+        'a zero-value boolean flag with key "{key}" is evaluated with default value "{default:bool}"',
+        extra_types={"bool": to_bool},
+    ),
+    target_fixture="key_and_default",
+)
+@when(
+    parsers.cfparse(
+        'a zero-value string flag with key "{key}" is evaluated with default value "{default}"',
+    ),
+    target_fixture="key_and_default",
+)
+@when(
+    parsers.cfparse(
+        'a string flag with key "{key}" is evaluated with default value "{default}"'
+    ),
+    target_fixture="key_and_default",
+)
+@when(
+    parsers.cfparse(
+        'a zero-value integer flag with key "{key}" is evaluated with default value {default:d}',
+    ),
+    target_fixture="key_and_default",
+)
+@when(
+    parsers.cfparse(
+        'an integer flag with key "{key}" is evaluated with default value {default:d}',
+    ),
+    target_fixture="key_and_default",
+)
+@when(
+    parsers.cfparse(
+        'a zero-value float flag with key "{key}" is evaluated with default value {default:f}',
+    ),
+    target_fixture="key_and_default",
+)
+def setup_key_and_default(
+    key: str, default: JsonPrimitive
+) -> typing.Tuple[str, JsonPrimitive]:
+    return (key, default)
+
+
+@when(
+    parsers.cfparse(
+        'a context containing a targeting key with value "{targeting_key}"'
+    ),
+)
+def assign_targeting_context(evaluation_context: EvaluationContext, targeting_key: str):
+    """a context containing a targeting key with value <targeting key>."""
+    evaluation_context.targeting_key = targeting_key
+
+
+@when(
+    parsers.cfparse('a context containing a key "{key}", with value "{value}"'),
+)
+@when(
+    parsers.cfparse('a context containing a key "{key}", with value {value:d}'),
+)
+def update_context(
+    evaluation_context: EvaluationContext, key: str, value: JsonPrimitive
+):
+    """a context containing a key and value."""
+    evaluation_context.attributes[key] = value
+
+
+@when(
+    parsers.cfparse(
+        'a context containing a nested property with outer key "{outer}" and inner key "{inner}", with value "{value}"'
+    ),
+)
+@when(
+    parsers.cfparse(
+        'a context containing a nested property with outer key "{outer}" and inner key "{inner}", with value {value:d}'
+    ),
+)
+def update_context_nested(
+    evaluation_context: EvaluationContext,
+    outer: str,
+    inner: str,
+    value: typing.Union[str, int],
+):
+    """a context containing a nested property with outer key, and inner key, and value."""
+    if outer not in evaluation_context.attributes:
+        evaluation_context.attributes[outer] = {}
+    evaluation_context.attributes[outer][inner] = value
+
+
+@then(
+    parsers.cfparse(
+        'the resolved boolean zero-value should be "{expected_value:bool}"',
+        extra_types={"bool": to_bool},
+    )
+)
+def assert_boolean_value(
+    client: OpenFeatureClient,
+    key_and_default: tuple,
+    expected_value: bool,
+    evaluation_context: EvaluationContext,
+):
+    key, default = key_and_default
+    evaluation_result = client.get_boolean_value(key, default, evaluation_context)
+    assert evaluation_result == expected_value
+
+
+@then(
+    parsers.cfparse(
+        "the resolved integer zero-value should be {expected_value:d}",
+    )
+)
+@then(parsers.cfparse("the returned value should be {expected_value:d}"))
+def assert_integer_value(
+    client: OpenFeatureClient,
+    key_and_default: tuple,
+    expected_value: bool,
+    evaluation_context: EvaluationContext,
+):
+    key, default = key_and_default
+    evaluation_result = client.get_integer_value(key, default, evaluation_context)
+    assert evaluation_result == expected_value
+
+
+@then(
+    parsers.cfparse(
+        "the resolved float zero-value should be {expected_value:f}",
+    )
+)
+def assert_float_value(
+    client: OpenFeatureClient,
+    key_and_default: tuple,
+    expected_value: bool,
+    evaluation_context: EvaluationContext,
+):
+    key, default = key_and_default
+    evaluation_result = client.get_float_value(key, default, evaluation_context)
+    assert evaluation_result == expected_value
+
+
+@then(parsers.cfparse('the returned value should be "{expected_value}"'))
+def assert_string_value(
+    client: OpenFeatureClient,
+    key_and_default: tuple,
+    expected_value: bool,
+    evaluation_context: EvaluationContext,
+):
+    key, default = key_and_default
+    evaluation_result = client.get_string_value(key, default, evaluation_context)
+    assert evaluation_result == expected_value
+
+
+@then(
+    parsers.cfparse(
+        'the resolved string zero-value should be ""',
+    )
+)
+def assert_empty_string(
+    client: OpenFeatureClient,
+    key_and_default: tuple,
+    evaluation_context: EvaluationContext,
+):
+    key, default = key_and_default
+    evaluation_result = client.get_string_value(key, default, evaluation_context)
+    assert evaluation_result == ""
+
+
+@then(parsers.cfparse('the returned reason should be "{reason}"'))
+def assert_reason(
+    client: OpenFeatureClient,
+    key_and_default: tuple,
+    evaluation_context: EvaluationContext,
+    reason: str,
+):
+    """the returned reason should be <reason>."""
+    key, default = key_and_default
+    evaluation_result = client.get_string_details(key, default, evaluation_context)
+    assert evaluation_result.reason.value == reason
diff --git a/providers/openfeature-provider-flagd/tests/e2e/parsers.py b/providers/openfeature-provider-flagd/tests/e2e/parsers.py
new file mode 100644
index 00000000..16e89d94
--- /dev/null
+++ b/providers/openfeature-provider-flagd/tests/e2e/parsers.py
@@ -0,0 +1,2 @@
+def to_bool(s: str) -> bool:
+    return s.lower() == "true"
diff --git a/providers/openfeature-provider-flagd/tests/e2e/test_inprocess_custom_ops.py b/providers/openfeature-provider-flagd/tests/e2e/test_inprocess_custom_ops.py
new file mode 100644
index 00000000..70ceb1aa
--- /dev/null
+++ b/providers/openfeature-provider-flagd/tests/e2e/test_inprocess_custom_ops.py
@@ -0,0 +1,38 @@
+import pytest
+from pytest_bdd import scenario
+from tests.conftest import setup_flag_file
+
+
+@pytest.fixture
+def flag_file(tmp_path):
+    return setup_flag_file(tmp_path, "custom-ops.json")
+
+
+@scenario(
+    "../../test-harness/gherkin/flagd-json-evaluator.feature", "Fractional operator"
+)
+def test_fractional_operator():
+    """Fractional operator."""
+
+
+@scenario(
+    "../../test-harness/gherkin/flagd-json-evaluator.feature",
+    "Semantic version operator numeric comparison",
+)
+def test_semantic_version_operator_numeric_comparison():
+    """Semantic version operator numeric comparison."""
+
+
+@scenario(
+    "../../test-harness/gherkin/flagd-json-evaluator.feature",
+    "Semantic version operator semantic comparison",
+)
+def test_semantic_version_operator_semantic_comparison():
+    """Semantic version operator semantic comparison."""
+
+
+@scenario(
+    "../../test-harness/gherkin/flagd-json-evaluator.feature", "Substring operators"
+)
+def test_substring_operators():
+    """Substring operators."""
diff --git a/providers/openfeature-provider-flagd/tests/e2e/test_inprocess_edge_cases.py b/providers/openfeature-provider-flagd/tests/e2e/test_inprocess_edge_cases.py
new file mode 100644
index 00000000..0583d8e9
--- /dev/null
+++ b/providers/openfeature-provider-flagd/tests/e2e/test_inprocess_edge_cases.py
@@ -0,0 +1,15 @@
+import pytest
+from pytest_bdd import scenario
+from tests.conftest import setup_flag_file
+
+
+@pytest.fixture
+def flag_file(tmp_path):
+    return setup_flag_file(tmp_path, "edge-case-flags.json")
+
+
+@scenario(
+    "../../test-harness/gherkin/flagd-json-evaluator.feature", "Errors and edge cases"
+)
+def test_errors_and_edge_cases():
+    """Errors and edge cases."""
diff --git a/providers/openfeature-provider-flagd/tests/e2e/test_inprocess_evaluator_reuse.py b/providers/openfeature-provider-flagd/tests/e2e/test_inprocess_evaluator_reuse.py
new file mode 100644
index 00000000..5abcddb5
--- /dev/null
+++ b/providers/openfeature-provider-flagd/tests/e2e/test_inprocess_evaluator_reuse.py
@@ -0,0 +1,13 @@
+import pytest
+from pytest_bdd import scenario
+from tests.conftest import setup_flag_file
+
+
+@pytest.fixture
+def flag_file(tmp_path):
+    return setup_flag_file(tmp_path, "evaluator-refs.json")
+
+
+@scenario("../../test-harness/gherkin/flagd-json-evaluator.feature", "Evaluator reuse")
+def test_evaluator_reuse():
+    """Evaluator reuse."""
diff --git a/providers/openfeature-provider-flagd/tests/e2e/test_inprocess_events.py b/providers/openfeature-provider-flagd/tests/e2e/test_inprocess_events.py
new file mode 100644
index 00000000..e00a4844
--- /dev/null
+++ b/providers/openfeature-provider-flagd/tests/e2e/test_inprocess_events.py
@@ -0,0 +1,91 @@
+import logging
+import time
+
+import pytest
+from pytest_bdd import parsers, scenario, then, when
+from tests.conftest import setup_flag_file
+
+from openfeature.client import OpenFeatureClient, ProviderEvent
+
+
+@scenario("../../test-harness/gherkin/flagd.feature", "Provider ready event")
+def test_ready_event(caplog):
+    """Provider ready event"""
+    caplog.set_level(logging.DEBUG)
+
+
+@scenario("../../test-harness/gherkin/flagd.feature", "Flag change event")
+def test_change_event():
+    """Flag change event"""
+
+
+@pytest.fixture
+def flag_file(tmp_path):
+    return setup_flag_file(tmp_path, "changing-flag-bar.json")
+
+
+@pytest.fixture
+def handles() -> list:
+    return []
+
+
+@when(
+    parsers.cfparse(
+        "a {event_type:ProviderEvent} handler is added",
+        extra_types={"ProviderEvent": ProviderEvent},
+    ),
+    target_fixture="handles",
+)
+def add_event_handler(
+    client: OpenFeatureClient, event_type: ProviderEvent, handles: list
+):
+    def handler(event):
+        handles.append(
+            {
+                "type": event_type,
+                "event": event,
+            }
+        )
+
+    client.add_handler(event_type, handler)
+    return handles
+
+
+@then(
+    parsers.cfparse(
+        "the {event_type:ProviderEvent} handler must run",
+        extra_types={"ProviderEvent": ProviderEvent},
+    )
+)
+def assert_handler_run(handles, event_type: ProviderEvent):
+    max_wait = 2
+    poll_interval = 0.1
+    while max_wait > 0:
+        if all(h["type"] != event_type for h in handles):
+            max_wait -= poll_interval
+            time.sleep(poll_interval)
+            continue
+        break
+
+    assert any(h["type"] == event_type for h in handles)
+
+
+@when(parsers.cfparse('a flag with key "{key}" is modified'))
+def modify_flag(flag_file, key):
+    time.sleep(0.1)  # guard against race condition
+    with open("test-harness/flags/changing-flag-foo.json") as src_file:
+        contents = src_file.read()
+    with open(flag_file, "w") as f:
+        f.write(contents)
+
+
+@then(parsers.cfparse('the event details must indicate "{key}" was altered'))
+def assert_flag_changed(handles, key):
+    handle = None
+    for h in handles:
+        if h["type"] == ProviderEvent.PROVIDER_CONFIGURATION_CHANGED:
+            handle = h
+            break
+
+    assert handle is not None
+    assert key in handle["event"].flags_changed
diff --git a/providers/openfeature-provider-flagd/tests/e2e/test_inprocess_testing_flags.py b/providers/openfeature-provider-flagd/tests/e2e/test_inprocess_testing_flags.py
new file mode 100644
index 00000000..4e3bd069
--- /dev/null
+++ b/providers/openfeature-provider-flagd/tests/e2e/test_inprocess_testing_flags.py
@@ -0,0 +1,24 @@
+import pytest
+from pytest_bdd import scenario
+from tests.conftest import setup_flag_file
+
+
+@pytest.fixture
+def flag_file(tmp_path):
+    return setup_flag_file(tmp_path, "testing-flags.json")
+
+
+@scenario(
+    "../../test-harness/gherkin/flagd-json-evaluator.feature",
+    "Time-based operations",
+)
+def test_timebased_operations():
+    """Time-based operations."""
+
+
+@scenario(
+    "../../test-harness/gherkin/flagd-json-evaluator.feature",
+    "Targeting by targeting key",
+)
+def test_targeting_by_targeting_key():
+    """Targeting by targeting key."""
diff --git a/providers/openfeature-provider-flagd/tests/e2e/test_inprocess_zero_evals.py b/providers/openfeature-provider-flagd/tests/e2e/test_inprocess_zero_evals.py
new file mode 100644
index 00000000..30de0dc6
--- /dev/null
+++ b/providers/openfeature-provider-flagd/tests/e2e/test_inprocess_zero_evals.py
@@ -0,0 +1,28 @@
+import pytest
+from pytest_bdd import scenario
+from tests.conftest import setup_flag_file
+
+
+@scenario("../../test-harness/gherkin/flagd.feature", "Resolves boolean zero value")
+def test_eval_boolean():
+    """Resolve boolean zero value"""
+
+
+@scenario("../../test-harness/gherkin/flagd.feature", "Resolves string zero value")
+def test_eval_string():
+    """Resolve string zero value"""
+
+
+@scenario("../../test-harness/gherkin/flagd.feature", "Resolves integer zero value")
+def test_eval_integer():
+    """Resolve integer zero value"""
+
+
+@scenario("../../test-harness/gherkin/flagd.feature", "Resolves float zero value")
+def test_eval_float():
+    """Resolve float zero value"""
+
+
+@pytest.fixture
+def flag_file(tmp_path):
+    return setup_flag_file(tmp_path, "zero-flags.json")
diff --git a/providers/openfeature-provider-flagd/tests/flags/basic-flag-broken-default.json b/providers/openfeature-provider-flagd/tests/flags/basic-flag-broken-default.json
new file mode 100644
index 00000000..4493399a
--- /dev/null
+++ b/providers/openfeature-provider-flagd/tests/flags/basic-flag-broken-default.json
@@ -0,0 +1,13 @@
+{
+  "flags": {
+    "basic-flag": {
+      "state": "ENABLED",
+      "variants": {
+        "true": true,
+        "false": false
+      },
+      "defaultVariant": 3,
+      "targeting": {}
+    }
+  }
+}
\ No newline at end of file
diff --git a/providers/openfeature-provider-flagd/tests/flags/basic-flag-broken-state.json b/providers/openfeature-provider-flagd/tests/flags/basic-flag-broken-state.json
new file mode 100644
index 00000000..514c06b8
--- /dev/null
+++ b/providers/openfeature-provider-flagd/tests/flags/basic-flag-broken-state.json
@@ -0,0 +1,13 @@
+{
+  "flags": {
+    "basic-flag": {
+      "state": 3,
+      "variants": {
+        "true": true,
+        "false": false
+      },
+      "defaultVariant": "false",
+      "targeting": {}
+    }
+  }
+}
\ No newline at end of file
diff --git a/providers/openfeature-provider-flagd/tests/flags/basic-flag-broken-targeting.json b/providers/openfeature-provider-flagd/tests/flags/basic-flag-broken-targeting.json
new file mode 100644
index 00000000..5623e05e
--- /dev/null
+++ b/providers/openfeature-provider-flagd/tests/flags/basic-flag-broken-targeting.json
@@ -0,0 +1,15 @@
+{
+  "flags": {
+    "basic-flag": {
+      "state": "ENABLED",
+      "variants": {
+        "true": true,
+        "false": false
+      },
+      "defaultVariant": "false",
+      "targeting": [
+        {"<": [1,3]}
+      ]
+    }
+  }
+}
\ No newline at end of file
diff --git a/providers/openfeature-provider-flagd/tests/flags/basic-flag-broken-variants.json b/providers/openfeature-provider-flagd/tests/flags/basic-flag-broken-variants.json
new file mode 100644
index 00000000..6bd94e52
--- /dev/null
+++ b/providers/openfeature-provider-flagd/tests/flags/basic-flag-broken-variants.json
@@ -0,0 +1,15 @@
+{
+  "flags": {
+    "basic-flag": {
+      "state": "ENABLED",
+      "variants": [
+        {
+          "true": true,
+          "false": false
+        }
+      ],
+      "defaultVariant": "false",
+      "targeting": {}
+    }
+  }
+}
\ No newline at end of file
diff --git a/providers/openfeature-provider-flagd/tests/flags/basic-flag-disabled.json b/providers/openfeature-provider-flagd/tests/flags/basic-flag-disabled.json
new file mode 100644
index 00000000..8713070b
--- /dev/null
+++ b/providers/openfeature-provider-flagd/tests/flags/basic-flag-disabled.json
@@ -0,0 +1,13 @@
+{
+    "flags": {
+      "basic-flag": {
+        "state": "DISABLED",
+        "variants": {
+          "true": true,
+          "false": false
+        },
+        "defaultVariant": "false",
+        "targeting": {}
+      }
+    }
+  }
\ No newline at end of file
diff --git a/providers/openfeature-provider-flagd/tests/flags/basic-flag-invalid.not-json b/providers/openfeature-provider-flagd/tests/flags/basic-flag-invalid.not-json
new file mode 100644
index 00000000..69434755
--- /dev/null
+++ b/providers/openfeature-provider-flagd/tests/flags/basic-flag-invalid.not-json
@@ -0,0 +1,13 @@
+{
+    "flags": {
+      "basic-flag": {
+        "state": "ENABLED",
+        "variants": {
+          "true": true,
+          "false": false
+        },
+        "defaultVariant": "false",
+        "targeting": {}
+      }
+    },
+  }
\ No newline at end of file
diff --git a/providers/openfeature-provider-flagd/tests/flags/basic-flag-no-state.json b/providers/openfeature-provider-flagd/tests/flags/basic-flag-no-state.json
new file mode 100644
index 00000000..2c05ee89
--- /dev/null
+++ b/providers/openfeature-provider-flagd/tests/flags/basic-flag-no-state.json
@@ -0,0 +1,12 @@
+{
+  "flags": {
+    "basic-flag": {
+      "variants": {
+        "true": true,
+        "false": false
+      },
+      "defaultVariant": "false",
+      "targeting": {}
+    }
+  }
+}
\ No newline at end of file
diff --git a/providers/openfeature-provider-flagd/tests/flags/basic-flag-wrong-structure.json b/providers/openfeature-provider-flagd/tests/flags/basic-flag-wrong-structure.json
new file mode 100644
index 00000000..2d5aeafe
--- /dev/null
+++ b/providers/openfeature-provider-flagd/tests/flags/basic-flag-wrong-structure.json
@@ -0,0 +1,11 @@
+{
+  "basic-flag": {
+    "state": "ENABLED",
+    "variants": {
+      "true": true,
+      "false": false
+    },
+    "defaultVariant": "false",
+    "targeting": {}
+  }
+}
\ No newline at end of file
diff --git a/providers/openfeature-provider-flagd/tests/flags/basic-flag-wrong-variant.json b/providers/openfeature-provider-flagd/tests/flags/basic-flag-wrong-variant.json
new file mode 100644
index 00000000..ea0f8abf
--- /dev/null
+++ b/providers/openfeature-provider-flagd/tests/flags/basic-flag-wrong-variant.json
@@ -0,0 +1,12 @@
+{
+  "flags": {
+    "basic-flag": {
+      "state": "ENABLED",
+      "variants": {
+        "true": true,
+        "false": false
+      },
+      "defaultVariant": "a-variant"
+    }
+  }
+}
\ No newline at end of file
diff --git a/providers/openfeature-provider-flagd/tests/flags/basic-flag.json b/providers/openfeature-provider-flagd/tests/flags/basic-flag.json
new file mode 100644
index 00000000..9322bd92
--- /dev/null
+++ b/providers/openfeature-provider-flagd/tests/flags/basic-flag.json
@@ -0,0 +1,13 @@
+{
+  "flags": {
+    "basic-flag": {
+      "state": "ENABLED",
+      "variants": {
+        "true": true,
+        "false": false
+      },
+      "defaultVariant": "false",
+      "targeting": {}
+    }
+  }
+}
\ No newline at end of file
diff --git a/providers/openfeature-provider-flagd/tests/flags/basic-flag.yaml b/providers/openfeature-provider-flagd/tests/flags/basic-flag.yaml
new file mode 100644
index 00000000..70a52e9a
--- /dev/null
+++ b/providers/openfeature-provider-flagd/tests/flags/basic-flag.yaml
@@ -0,0 +1,8 @@
+flags:
+  basic-flag:
+    state: ENABLED
+    variants:
+      "true": true
+      "false": false
+    defaultVariant: "false"
+    targeting: {}
\ No newline at end of file
diff --git a/providers/openfeature-provider-flagd/tests/flags/invalid-fractional-args.json b/providers/openfeature-provider-flagd/tests/flags/invalid-fractional-args.json
new file mode 100644
index 00000000..0a15b804
--- /dev/null
+++ b/providers/openfeature-provider-flagd/tests/flags/invalid-fractional-args.json
@@ -0,0 +1,16 @@
+{
+  "flags": {
+    "basic-flag": {
+      "state": "ENABLED",
+      "variants": {
+        "default": "default",
+        "true": "true",
+        "false": "false"
+      },
+      "defaultVariant": "default",
+      "targeting": {
+        "fractional": []
+      }
+    }
+  }
+}
\ No newline at end of file
diff --git a/providers/openfeature-provider-flagd/tests/flags/invalid-fractional-weights.json b/providers/openfeature-provider-flagd/tests/flags/invalid-fractional-weights.json
new file mode 100644
index 00000000..105b70ac
--- /dev/null
+++ b/providers/openfeature-provider-flagd/tests/flags/invalid-fractional-weights.json
@@ -0,0 +1,19 @@
+{
+  "flags": {
+    "basic-flag": {
+      "state": "ENABLED",
+      "variants": {
+        "default": "default",
+        "true": "true",
+        "false": "false"
+      },
+      "defaultVariant": "default",
+      "targeting": {
+        "fractional": [
+          [3, 50],
+          [4, 50]
+        ]
+      }
+    }
+  }
+}
\ No newline at end of file
diff --git a/providers/openfeature-provider-flagd/tests/flags/invalid-semver-args.json b/providers/openfeature-provider-flagd/tests/flags/invalid-semver-args.json
new file mode 100644
index 00000000..a16e5ec7
--- /dev/null
+++ b/providers/openfeature-provider-flagd/tests/flags/invalid-semver-args.json
@@ -0,0 +1,16 @@
+{
+  "flags": {
+    "basic-flag": {
+      "state": "ENABLED",
+      "variants": {
+        "default": "default",
+        "true": "true",
+        "false": "false"
+      },
+      "defaultVariant": "default",
+      "targeting": {
+        "sem_ver": ["1.0.0", "similar to", "1.0.0", "2.0.0"]
+      }
+    }
+  }
+}
\ No newline at end of file
diff --git a/providers/openfeature-provider-flagd/tests/flags/invalid-semver-op.json b/providers/openfeature-provider-flagd/tests/flags/invalid-semver-op.json
new file mode 100644
index 00000000..44e3263e
--- /dev/null
+++ b/providers/openfeature-provider-flagd/tests/flags/invalid-semver-op.json
@@ -0,0 +1,16 @@
+{
+  "flags": {
+    "basic-flag": {
+      "state": "ENABLED",
+      "variants": {
+        "default": "default",
+        "true": "true",
+        "false": "false"
+      },
+      "defaultVariant": "default",
+      "targeting": {
+        "sem_ver": ["1.0.0", "similar to", "1.0.0"]
+      }
+    }
+  }
+}
\ No newline at end of file
diff --git a/providers/openfeature-provider-flagd/tests/flags/invalid-stringcomp-args.json b/providers/openfeature-provider-flagd/tests/flags/invalid-stringcomp-args.json
new file mode 100644
index 00000000..a43afedc
--- /dev/null
+++ b/providers/openfeature-provider-flagd/tests/flags/invalid-stringcomp-args.json
@@ -0,0 +1,16 @@
+{
+  "flags": {
+    "basic-flag": {
+      "state": "ENABLED",
+      "variants": {
+        "default": "default",
+        "true": "true",
+        "false": "false"
+      },
+      "defaultVariant": "default",
+      "targeting": {
+        "starts_with": ["abcdefg", "abc", "def"]
+      }
+    }
+  }
+}
\ No newline at end of file
diff --git a/providers/openfeature-provider-flagd/tests/test_errors.py b/providers/openfeature-provider-flagd/tests/test_errors.py
new file mode 100644
index 00000000..4adb332e
--- /dev/null
+++ b/providers/openfeature-provider-flagd/tests/test_errors.py
@@ -0,0 +1,79 @@
+import pytest
+
+from openfeature import api
+from openfeature.contrib.provider.flagd import FlagdProvider
+from openfeature.contrib.provider.flagd.config import ResolverType
+from openfeature.evaluation_context import EvaluationContext
+from openfeature.exception import ErrorCode
+from openfeature.flag_evaluation import Reason
+
+
+def create_client(provider: FlagdProvider):
+    api.set_provider(provider)
+    return api.get_client()
+
+
+@pytest.mark.parametrize(
+    "file_name",
+    [
+        "not-a-flag.json",
+        "basic-flag-wrong-structure.json",
+        "basic-flag-invalid.not-json",
+        "basic-flag-wrong-variant.json",
+        "basic-flag-broken-state.json",
+        "basic-flag-broken-variants.json",
+        "basic-flag-broken-default.json",
+        "basic-flag-broken-targeting.json",
+    ],
+)
+def test_file_load_errors(file_name: str):
+    client = create_client(
+        FlagdProvider(
+            resolver_type=ResolverType.IN_PROCESS,
+            offline_flag_source_path=f"tests/flags/{file_name}",
+        )
+    )
+
+    res = client.get_boolean_details("basic-flag", False)
+
+    assert res.value is False
+    assert res.reason == Reason.ERROR
+    assert res.error_code == ErrorCode.FLAG_NOT_FOUND
+
+
+@pytest.mark.parametrize(
+    "file_name",
+    [
+        "invalid-semver-op.json",
+        "invalid-semver-args.json",
+        "invalid-stringcomp-args.json",
+        "invalid-fractional-args.json",
+        "invalid-fractional-weights.json",
+    ],
+)
+def test_json_logic_parse_errors(file_name: str):
+    client = create_client(
+        FlagdProvider(
+            resolver_type=ResolverType.IN_PROCESS,
+            offline_flag_source_path=f"tests/flags/{file_name}",
+        )
+    )
+
+    res = client.get_string_details("basic-flag", "fallback", EvaluationContext("123"))
+
+    assert res.value == "default"
+    assert res.reason == Reason.DEFAULT
+
+
+def test_flag_disabled():
+    client = create_client(
+        FlagdProvider(
+            resolver_type=ResolverType.IN_PROCESS,
+            offline_flag_source_path="tests/flags/basic-flag-disabled.json",
+        )
+    )
+
+    res = client.get_string_details("basic-flag", "fallback", EvaluationContext("123"))
+
+    assert res.value == "fallback"
+    assert res.reason == Reason.DISABLED
diff --git a/providers/openfeature-provider-flagd/tests/test_file_store.py b/providers/openfeature-provider-flagd/tests/test_file_store.py
new file mode 100644
index 00000000..2ae98ffa
--- /dev/null
+++ b/providers/openfeature-provider-flagd/tests/test_file_store.py
@@ -0,0 +1,33 @@
+from unittest.mock import Mock
+
+import pytest
+from src.openfeature.contrib.provider.flagd.resolvers.process.file_watcher import (
+    FileWatcherFlagStore,
+)
+from src.openfeature.contrib.provider.flagd.resolvers.process.flags import Flag
+
+from openfeature import api
+from openfeature.contrib.provider.flagd import FlagdProvider
+from openfeature.provider.provider import AbstractProvider
+
+
+def create_client(provider: FlagdProvider):
+    api.set_provider(provider)
+    return api.get_client()
+
+
+@pytest.mark.parametrize(
+    "file_name",
+    [
+        "basic-flag.json",
+        "basic-flag.yaml",
+    ],
+)
+def test_file_load_errors(file_name: str):
+    provider = Mock(spec=AbstractProvider)
+    file_store = FileWatcherFlagStore(f"tests/flags/{file_name}", provider)
+
+    flag = file_store.flag_data.get("basic-flag")
+
+    assert flag is not None
+    assert isinstance(flag, Flag)