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 `_common` suffix that are imported by both siblings: `impl/client_common.py`, `impl/datasystem/fdv2_common.py`, and `impl/events/event_processor_common.py`. Per-side logic that genuinely differs (more than `async`/`await`) lives directly in each sibling, not in a separate `_common` module — 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_common import (
get_environment_metadata,
get_plugin_hooks
)
from ldclient.impl.client_common 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, "python-server-sdk")
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
79 changes: 77 additions & 2 deletions ldclient/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,12 +160,87 @@ def disable_ssl_verification(self) -> bool:
T_co = TypeVar("T_co", covariant=True)


class SdkIdentityConfig(Protocol):
"""
The SDK's self-identity fields — read by the shared environment-metadata and
secure-mode helpers, and included in request headers.
"""

@property
def sdk_key(self) -> Optional[str]:
"""The SDK key used to authenticate requests."""

@property
def application(self) -> dict:
"""The application metadata included in request headers."""

@property
def wrapper_name(self) -> Optional[str]:
"""The wrapper name included in request headers."""

@property
def wrapper_version(self) -> Optional[str]:
"""The wrapper version included in request headers."""


class DataSourceBuilderConfig(SdkIdentityConfig, Protocol):
"""
The subset of configuration that data source builders and the data sources
they build read. Extends :class:`SdkIdentityConfig` with the transport and
endpoint fields. It is the structural contract a config object must satisfy
to be passed to :meth:`DataSourceBuilder.build`.
"""

@property
def base_uri(self) -> str:
"""The base URI for polling requests."""

@property
def stream_base_uri(self) -> str:
"""The base URI for streaming requests."""

@property
def http(self) -> HTTPConfig:
"""The HTTP configuration used for requests."""

@property
def initial_reconnect_delay(self) -> float:
"""The initial reconnect delay for the streaming data source."""

@property
def poll_interval(self) -> float:
"""The interval between polling requests."""

@property
def payload_filter_key(self) -> Optional[str]:
"""The payload filter key applied as a query parameter."""

# Set by the client after construction, so it is a read-write attribute
# rather than a read-only property.
_instance_id: Optional[str]


class PrivateAttributesConfig(Protocol):
"""
The private-attribute redaction settings read by the event output formatter
when building analytics events.
"""

@property
def all_attributes_private(self) -> bool:
"""Whether all context attributes should be treated as private."""

@property
def private_attributes(self) -> List[str]:
"""The context attribute references to treat as private."""


class DataSourceBuilder(Protocol[T_co]): # pylint: disable=too-few-public-methods
"""
Protocol for building data sources.
"""

def build(self, config: 'Config') -> T_co:
def build(self, config: DataSourceBuilderConfig) -> T_co:
"""
Builds the data source.

Expand Down Expand Up @@ -199,7 +274,7 @@ class DataSystemConfig:
"""An optional fallback synchronizer that will read from FDv1"""


class Config:
class Config(DataSourceBuilderConfig, PrivateAttributesConfig):
"""Advanced configuration options for the SDK client.

To use these options, create an instance of ``Config`` and pass it to either :func:`ldclient.set_config()`
Expand Down
66 changes: 66 additions & 0 deletions ldclient/impl/client_common.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, SdkIdentityConfig
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: SdkIdentityConfig, sdk_name: str) -> EnvironmentMetadata:
sdk_metadata = SdkMetadata(
name=sdk_name,
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: SdkIdentityConfig, 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()
14 changes: 9 additions & 5 deletions ldclient/impl/datasourcev2/polling.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@

import urllib3

from ldclient.config import Config, DataSourceBuilder, HTTPConfig
from ldclient.config import (
DataSourceBuilder,
DataSourceBuilderConfig,
HTTPConfig
)
from ldclient.impl.datasource.feature_requester import FDV1_POLLING_ENDPOINT
from ldclient.impl.datasystem.protocolv2 import (
DeleteObject,
Expand Down Expand Up @@ -257,7 +261,7 @@ class Urllib3PollingRequester(Requester):
requests.
"""

def __init__(self, config: Config, base_uri: str, http_options: HTTPConfig):
def __init__(self, config: DataSourceBuilderConfig, base_uri: str, http_options: HTTPConfig):
self._etag = None
factory = HTTPFactory(_base_headers(config), http_options)
self._http = factory.create_pool_manager(1, base_uri)
Expand Down Expand Up @@ -422,7 +426,7 @@ def requester(self, requester: Requester) -> 'PollingDataSourceBuilder':
self.__requester = requester
return self

def build(self, config: Config) -> PollingDataSource:
def build(self, config: DataSourceBuilderConfig) -> PollingDataSource:
"""Builds the PollingDataSource with the configured parameters."""
requester = (
self.__requester
Expand Down Expand Up @@ -465,7 +469,7 @@ def http_options(self, http_options: HTTPConfig) -> 'FallbackToFDv1PollingDataSo
self.__http_options = http_options
return self

def build(self, config: Config) -> PollingDataSource:
def build(self, config: DataSourceBuilderConfig) -> PollingDataSource:
"""Builds the PollingDataSource with the configured parameters."""
builder = PollingDataSourceBuilder()
builder.requester(
Expand All @@ -487,7 +491,7 @@ class Urllib3FDv1PollingRequester(Requester):
requests.
"""

def __init__(self, config: Config, base_uri: str, http_options: HTTPConfig):
def __init__(self, config: DataSourceBuilderConfig, base_uri: str, http_options: HTTPConfig):
self._etag = None
self._http = HTTPFactory(_base_headers(config), http_options).create_pool_manager(
1, base_uri
Expand Down
14 changes: 9 additions & 5 deletions ldclient/impl/datasourcev2/streaming.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@
)
from ld_eventsource.errors import HTTPStatusError

from ldclient.config import Config, DataSourceBuilder, HTTPConfig
from ldclient.config import (
DataSourceBuilder,
DataSourceBuilderConfig,
HTTPConfig
)
from ldclient.impl.datasystem import DiagnosticAccumulator, DiagnosticSource
from ldclient.impl.datasystem.protocolv2 import (
DeleteObject,
Expand Down Expand Up @@ -59,7 +63,7 @@
STREAMING_ENDPOINT = "/sdk/stream"

SseClientBuilder = Callable[
[str, HTTPConfig, float, Config, SelectorStore],
[str, HTTPConfig, float, DataSourceBuilderConfig, SelectorStore],
Tuple[SSEClient, Optional[urllib3.PoolManager]],
]

Expand All @@ -68,7 +72,7 @@ def create_sse_client(
base_uri: str,
http_options: HTTPConfig,
initial_reconnect_delay: float,
config: Config,
config: DataSourceBuilderConfig,
ss: SelectorStore
) -> Tuple[SSEClient, Optional[urllib3.PoolManager]]:
""" "
Expand Down Expand Up @@ -158,7 +162,7 @@ def __init__(self,
uri: str,
http_options: HTTPConfig,
initial_reconnect_delay: float,
config: Config):
config: DataSourceBuilderConfig):
self.__uri = uri
self.__http_options = http_options
self.__initial_reconnect_delay = initial_reconnect_delay
Expand Down Expand Up @@ -519,7 +523,7 @@ def http_options(self, http_options: HTTPConfig) -> 'StreamingDataSourceBuilder'
self.__http_options = http_options
return self

def build(self, config: Config) -> StreamingDataSource:
def build(self, config: DataSourceBuilderConfig) -> StreamingDataSource:
"""Builds a StreamingDataSource instance with the configured parameters."""
return StreamingDataSource(
self.__base_uri or config.stream_base_uri,
Expand Down
Loading
Loading