Skip to content
Open
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
4 changes: 4 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ A special case is the module `ldclient.impl`, and any modules within it. Everyth

So, if there is a class whose existence is entirely an implementation detail, it should be in `impl`. Similarly, classes that are _not_ in `impl` must not expose any public members (i.e. symbols that do not have an underscore prefix) that are not meant to be part of the supported public API. This is important because of our guarantee of backward compatibility for all public APIs within a major version: we want to be able to change our implementation details to suit the needs of the code, without worrying about breaking a customer's code. Due to how the language works, we can't actually prevent an application developer from referencing those classes in their code, but this convention makes it clear that such use is discouraged and unsupported.

### Sync/async parity

The SDK maintains parallel sync (`foo.py`) and async (`async_foo.py`) implementations by hand. When you change a method in a sync module, make the matching change in its `async_` sibling (and vice versa), and justify any difference beyond `async`/`await` keywords. Shared I/O-free ("sans-I/O") logic lives in modules with a `_core` suffix that are imported by both siblings: `impl/client_core.py`, `impl/datasystem/fdv2_core.py`, and `impl/events/event_processor_core.py`. Per-side logic that genuinely differs (more than `async`/`await`) lives directly in each sibling, not in a separate core file — for example `impl/evaluator.py`/`impl/async_evaluator.py` and the in-memory feature stores `feature_store.py`/`async_feature_store.py`. Both the sync and async contract test suites must pass.

### Type hints

Python does not require the use of type hints, but they can be extremely helpful for spotting mistakes and for improving the IDE experience, so we should always use them in the SDK. Every method in the public API is expected to have type hints for all non-`self` parameters, and for its return value if any.
Expand Down
53 changes: 9 additions & 44 deletions ldclient/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@
This submodule contains the client class that provides most of the SDK functionality.
"""

import hashlib
import hmac
import threading
import traceback
from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple
Expand All @@ -19,6 +17,11 @@
_EvaluationWithHookResult
)
from ldclient.impl.big_segments import BigSegmentStoreManager
from ldclient.impl.client_core import (
get_environment_metadata,
get_plugin_hooks
)
from ldclient.impl.client_core import secure_mode_hash as _secure_mode_hash
from ldclient.impl.datasource.feature_requester import FeatureRequesterImpl
from ldclient.impl.datasource.polling import PollingUpdateProcessor
from ldclient.impl.datasource.status import (
Expand Down Expand Up @@ -57,12 +60,7 @@
ReadOnlyStore
)
from ldclient.migrations import OpTracker, Stage
from ldclient.plugin import (
ApplicationMetadata,
EnvironmentMetadata,
SdkMetadata
)
from ldclient.version import VERSION
from ldclient.plugin import EnvironmentMetadata
from ldclient.versioned_data_kind import FEATURES, SEGMENTS, VersionedDataKind

from .impl import AnyNum
Expand Down Expand Up @@ -239,8 +237,8 @@ def postfork(self, start_wait: float = 5):
self.__start_up(start_wait)

def __start_up(self, start_wait: float):
environment_metadata = self.__get_environment_metadata()
plugin_hooks = self.__get_plugin_hooks(environment_metadata)
environment_metadata = get_environment_metadata(self._config)
plugin_hooks = get_plugin_hooks(self._config, environment_metadata)

self.__hooks_lock = ReadWriteLock()
self.__hooks = self._config.hooks + plugin_hooks # type: List[Hook]
Expand Down Expand Up @@ -305,36 +303,6 @@ def __start_up(self, start_wait: float):
else:
log.warning("Initialization timeout exceeded for LaunchDarkly Client or an error occurred. " "Feature Flags may not yet be available.")

def __get_environment_metadata(self) -> EnvironmentMetadata:
sdk_metadata = SdkMetadata(
name="python-server-sdk",
version=VERSION,
wrapper_name=self._config.wrapper_name,
wrapper_version=self._config.wrapper_version
)

application_metadata = None
if self._config.application:
application_metadata = ApplicationMetadata(
id=self._config.application.get('id'),
version=self._config.application.get('version'),
)

return EnvironmentMetadata(
sdk=sdk_metadata,
application=application_metadata,
sdk_key=self._config.sdk_key
)

def __get_plugin_hooks(self, environment_metadata: EnvironmentMetadata) -> List[Hook]:
hooks = []
for plugin in self._config.plugins:
try:
hooks.extend(plugin.get_hooks(environment_metadata))
except Exception as e:
log.error("Error getting hooks from plugin %s: %s", plugin.metadata.name, e)
return hooks

def __register_plugins(self, environment_metadata: EnvironmentMetadata):
for plugin in self._config.plugins:
try:
Expand Down Expand Up @@ -693,10 +661,7 @@ def secure_mode_hash(self, context: Context) -> str:
:param context: the evaluation context
:return: the hash string
"""
if not context.valid:
log.warning("Context was invalid for secure_mode_hash (%s); returning empty hash" % context.error)
return ""
return hmac.new(str(self._config.sdk_key).encode(), context.fully_qualified_key.encode(), hashlib.sha256).hexdigest()
return _secure_mode_hash(self._config, context)

def add_hook(self, hook: Hook):
"""
Expand Down
66 changes: 66 additions & 0 deletions ldclient/impl/client_core.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""
Genuinely I/O-free, await-free helpers shared by the sync :class:`ldclient.client.LDClient`
and async :class:`ldclient.async_client.AsyncLDClient`.

These functions contain no awaits and touch no store/network/event I/O, so they
can live in a single module imported by both clients. Anything that reads the
store, sends events, or runs hook stages is I/O-adjacent and is hand-duplicated
across the two client classes instead (differing only in ``async``/``await``).
"""

import hashlib
import hmac
from typing import List

from ldclient.config import Config
from ldclient.context import Context
from ldclient.hook import Hook
from ldclient.impl.util import log
from ldclient.plugin import (
ApplicationMetadata,
EnvironmentMetadata,
SdkMetadata
)
from ldclient.version import VERSION


def get_environment_metadata(config: Config) -> EnvironmentMetadata:
sdk_metadata = SdkMetadata(
name="python-server-sdk",
version=VERSION,
wrapper_name=config.wrapper_name,
wrapper_version=config.wrapper_version
)

application_metadata = None
if config.application:
application_metadata = ApplicationMetadata(
id=config.application.get('id'),
version=config.application.get('version'),
)

return EnvironmentMetadata(
sdk=sdk_metadata,
application=application_metadata,
sdk_key=config.sdk_key
)


def get_plugin_hooks(config: Config, environment_metadata: EnvironmentMetadata) -> List[Hook]:
hooks = []
for plugin in config.plugins:
try:
hooks.extend(plugin.get_hooks(environment_metadata))
except Exception as e:
log.error("Error getting hooks from plugin %s: %s", plugin.metadata.name, e)
return hooks


def secure_mode_hash(config: Config, context: Context) -> str:
"""Computes the secure-mode HMAC for a context, or an empty string for an
invalid context. Pure: depends only on the SDK key and the context's
fully-qualified key."""
if not context.valid:
log.warning("Context was invalid for secure_mode_hash (%s); returning empty hash" % context.error)
return ""
return hmac.new(str(config.sdk_key).encode(), context.fully_qualified_key.encode(), hashlib.sha256).hexdigest()
Loading
Loading