Skip to content

Commit d10e1a7

Browse files
committed
refactor: Extract shared sans-I/O cores from sync data paths
1 parent 25c2b8d commit d10e1a7

7 files changed

Lines changed: 621 additions & 494 deletions

File tree

CONTRIBUTING.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,10 @@ A special case is the module `ldclient.impl`, and any modules within it. Everyth
7878

7979
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.
8080

81+
### Sync/async parity
82+
83+
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.
84+
8185
### Type hints
8286

8387
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.

ldclient/client.py

Lines changed: 9 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@
22
This submodule contains the client class that provides most of the SDK functionality.
33
"""
44

5-
import hashlib
6-
import hmac
75
import threading
86
import traceback
97
from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple
@@ -19,6 +17,11 @@
1917
_EvaluationWithHookResult
2018
)
2119
from ldclient.impl.big_segments import BigSegmentStoreManager
20+
from ldclient.impl.client_common import (
21+
get_environment_metadata,
22+
get_plugin_hooks
23+
)
24+
from ldclient.impl.client_common import secure_mode_hash as _secure_mode_hash
2225
from ldclient.impl.datasource.feature_requester import FeatureRequesterImpl
2326
from ldclient.impl.datasource.polling import PollingUpdateProcessor
2427
from ldclient.impl.datasource.status import (
@@ -57,12 +60,7 @@
5760
ReadOnlyStore
5861
)
5962
from ldclient.migrations import OpTracker, Stage
60-
from ldclient.plugin import (
61-
ApplicationMetadata,
62-
EnvironmentMetadata,
63-
SdkMetadata
64-
)
65-
from ldclient.version import VERSION
63+
from ldclient.plugin import EnvironmentMetadata
6664
from ldclient.versioned_data_kind import FEATURES, SEGMENTS, VersionedDataKind
6765

6866
from .impl import AnyNum
@@ -239,8 +237,8 @@ def postfork(self, start_wait: float = 5):
239237
self.__start_up(start_wait)
240238

241239
def __start_up(self, start_wait: float):
242-
environment_metadata = self.__get_environment_metadata()
243-
plugin_hooks = self.__get_plugin_hooks(environment_metadata)
240+
environment_metadata = get_environment_metadata(self._config, "python-server-sdk")
241+
plugin_hooks = get_plugin_hooks(self._config, environment_metadata)
244242

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

308-
def __get_environment_metadata(self) -> EnvironmentMetadata:
309-
sdk_metadata = SdkMetadata(
310-
name="python-server-sdk",
311-
version=VERSION,
312-
wrapper_name=self._config.wrapper_name,
313-
wrapper_version=self._config.wrapper_version
314-
)
315-
316-
application_metadata = None
317-
if self._config.application:
318-
application_metadata = ApplicationMetadata(
319-
id=self._config.application.get('id'),
320-
version=self._config.application.get('version'),
321-
)
322-
323-
return EnvironmentMetadata(
324-
sdk=sdk_metadata,
325-
application=application_metadata,
326-
sdk_key=self._config.sdk_key
327-
)
328-
329-
def __get_plugin_hooks(self, environment_metadata: EnvironmentMetadata) -> List[Hook]:
330-
hooks = []
331-
for plugin in self._config.plugins:
332-
try:
333-
hooks.extend(plugin.get_hooks(environment_metadata))
334-
except Exception as e:
335-
log.error("Error getting hooks from plugin %s: %s", plugin.metadata.name, e)
336-
return hooks
337-
338306
def __register_plugins(self, environment_metadata: EnvironmentMetadata):
339307
for plugin in self._config.plugins:
340308
try:
@@ -693,10 +661,7 @@ def secure_mode_hash(self, context: Context) -> str:
693661
:param context: the evaluation context
694662
:return: the hash string
695663
"""
696-
if not context.valid:
697-
log.warning("Context was invalid for secure_mode_hash (%s); returning empty hash" % context.error)
698-
return ""
699-
return hmac.new(str(self._config.sdk_key).encode(), context.fully_qualified_key.encode(), hashlib.sha256).hexdigest()
664+
return _secure_mode_hash(self._config, context)
700665

701666
def add_hook(self, hook: Hook):
702667
"""

ldclient/impl/client_common.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""
2+
Genuinely I/O-free, await-free helpers shared by the sync :class:`ldclient.client.LDClient`
3+
and async :class:`ldclient.async_client.AsyncLDClient`.
4+
5+
These functions contain no awaits and touch no store/network/event I/O, so they
6+
can live in a single module imported by both clients. Anything that reads the
7+
store, sends events, or runs hook stages is I/O-adjacent and is hand-duplicated
8+
across the two client classes instead (differing only in ``async``/``await``).
9+
"""
10+
11+
import hashlib
12+
import hmac
13+
from typing import List
14+
15+
from ldclient.config import Config
16+
from ldclient.context import Context
17+
from ldclient.hook import Hook
18+
from ldclient.impl.util import log
19+
from ldclient.plugin import (
20+
ApplicationMetadata,
21+
EnvironmentMetadata,
22+
SdkMetadata
23+
)
24+
from ldclient.version import VERSION
25+
26+
27+
def get_environment_metadata(config: Config, sdk_name: str) -> EnvironmentMetadata:
28+
sdk_metadata = SdkMetadata(
29+
name=sdk_name,
30+
version=VERSION,
31+
wrapper_name=config.wrapper_name,
32+
wrapper_version=config.wrapper_version
33+
)
34+
35+
application_metadata = None
36+
if config.application:
37+
application_metadata = ApplicationMetadata(
38+
id=config.application.get('id'),
39+
version=config.application.get('version'),
40+
)
41+
42+
return EnvironmentMetadata(
43+
sdk=sdk_metadata,
44+
application=application_metadata,
45+
sdk_key=config.sdk_key
46+
)
47+
48+
49+
def get_plugin_hooks(config: Config, environment_metadata: EnvironmentMetadata) -> List[Hook]:
50+
hooks = []
51+
for plugin in config.plugins:
52+
try:
53+
hooks.extend(plugin.get_hooks(environment_metadata))
54+
except Exception as e:
55+
log.error("Error getting hooks from plugin %s: %s", plugin.metadata.name, e)
56+
return hooks
57+
58+
59+
def secure_mode_hash(config: Config, context: Context) -> str:
60+
"""Computes the secure-mode HMAC for a context, or an empty string for an
61+
invalid context. Pure: depends only on the SDK key and the context's
62+
fully-qualified key."""
63+
if not context.valid:
64+
log.warning("Context was invalid for secure_mode_hash (%s); returning empty hash" % context.error)
65+
return ""
66+
return hmac.new(str(config.sdk_key).encode(), context.fully_qualified_key.encode(), hashlib.sha256).hexdigest()

0 commit comments

Comments
 (0)