|
| 1 | +import asyncio |
| 2 | +import json |
| 3 | +import logging |
| 4 | +from typing import Optional |
| 5 | + |
| 6 | +import requests |
| 7 | +from async_big_segment_store_fixture import AsyncBigSegmentStoreFixture |
| 8 | +from async_flag_change_listener import AsyncListenerRegistry |
| 9 | +from hook import AsyncPostingHook |
| 10 | + |
| 11 | +from ldclient import Context |
| 12 | +from ldclient.async_client import AsyncLDClient |
| 13 | +from ldclient.async_config import AsyncBigSegmentsConfig, AsyncConfig |
| 14 | +from ldclient.impl.util import Result |
| 15 | +from ldclient.migrations import ( |
| 16 | + AsyncMigratorBuilder, |
| 17 | + ExecutionOrder, |
| 18 | + Operation, |
| 19 | + Stage |
| 20 | +) |
| 21 | + |
| 22 | + |
| 23 | +class AsyncClientEntity: |
| 24 | + def __init__(self, tag: str, config_params: dict): |
| 25 | + self.log = logging.getLogger(tag) |
| 26 | + self._client: Optional[AsyncLDClient] = None |
| 27 | + self._listeners: Optional[AsyncListenerRegistry] = None |
| 28 | + self._config_params = config_params |
| 29 | + self._tag = tag |
| 30 | + |
| 31 | + async def start(self): |
| 32 | + """Build the AsyncLDClient and wait for it to initialize.""" |
| 33 | + config_params = self._config_params |
| 34 | + opts = {"sdk_key": config_params["credential"]} |
| 35 | + |
| 36 | + tags = config_params.get('tags', {}) |
| 37 | + if tags: |
| 38 | + opts['application'] = { |
| 39 | + 'id': tags.get('applicationId', ''), |
| 40 | + 'version': tags.get('applicationVersion', ''), |
| 41 | + } |
| 42 | + |
| 43 | + datasystem_config = config_params.get('dataSystem') |
| 44 | + if datasystem_config is not None: |
| 45 | + raise NotImplementedError("FDv2 (dataSystem) is not yet supported in the async contract-test service") |
| 46 | + elif config_params.get("streaming") is not None: |
| 47 | + streaming = config_params["streaming"] |
| 48 | + if streaming.get("baseUri") is not None: |
| 49 | + opts["stream_uri"] = streaming["baseUri"] |
| 50 | + if streaming.get("filter") is not None: |
| 51 | + opts["payload_filter_key"] = streaming["filter"] |
| 52 | + _set_optional_time_prop(streaming, "initialRetryDelayMs", opts, "initial_reconnect_delay") |
| 53 | + elif config_params.get("polling") is not None: |
| 54 | + opts['stream'] = False |
| 55 | + polling = config_params["polling"] |
| 56 | + if polling.get("baseUri") is not None: |
| 57 | + opts["base_uri"] = polling["baseUri"] |
| 58 | + if polling.get("filter") is not None: |
| 59 | + opts["payload_filter_key"] = polling["filter"] |
| 60 | + _set_optional_time_prop(polling, "pollIntervalMs", opts, "poll_interval") |
| 61 | + else: |
| 62 | + opts['use_ldd'] = True |
| 63 | + |
| 64 | + if config_params.get("events") is not None: |
| 65 | + events = config_params["events"] |
| 66 | + opts["enable_event_compression"] = events.get("enableGzip", False) |
| 67 | + if events.get("baseUri") is not None: |
| 68 | + opts["events_uri"] = events["baseUri"] |
| 69 | + if events.get("capacity") is not None: |
| 70 | + opts["events_max_pending"] = events["capacity"] |
| 71 | + opts["diagnostic_opt_out"] = not events.get("enableDiagnostics", False) |
| 72 | + opts["all_attributes_private"] = events.get("allAttributesPrivate", False) |
| 73 | + opts["private_attributes"] = events.get("globalPrivateAttributes", {}) |
| 74 | + _set_optional_time_prop(events, "flushIntervalMs", opts, "flush_interval") |
| 75 | + opts["omit_anonymous_contexts"] = events.get("omitAnonymousContexts", False) |
| 76 | + else: |
| 77 | + opts["send_events"] = False |
| 78 | + |
| 79 | + hooks = [] |
| 80 | + if config_params.get("hooks") is not None: |
| 81 | + hooks = [ |
| 82 | + AsyncPostingHook(h["name"], h["callbackUri"], h.get("data", {}), h.get("errors", {})) |
| 83 | + for h in config_params["hooks"]["hooks"] |
| 84 | + ] |
| 85 | + |
| 86 | + if config_params.get("bigSegments") is not None: |
| 87 | + big_params = config_params["bigSegments"] |
| 88 | + big_config = {"store": AsyncBigSegmentStoreFixture(big_params["callbackUri"])} |
| 89 | + if big_params.get("userCacheSize") is not None: |
| 90 | + big_config["context_cache_size"] = big_params["userCacheSize"] |
| 91 | + _set_optional_time_prop(big_params, "userCacheTimeMs", big_config, "context_cache_time") |
| 92 | + _set_optional_time_prop(big_params, "statusPollIntervalMs", big_config, "status_poll_interval") |
| 93 | + _set_optional_time_prop(big_params, "staleAfterMs", big_config, "stale_after") |
| 94 | + opts["big_segments"] = AsyncBigSegmentsConfig(**big_config) |
| 95 | + |
| 96 | + start_wait = config_params.get("startWaitTimeMs") or 5000 |
| 97 | + sdk_config = AsyncConfig(**opts) |
| 98 | + |
| 99 | + self._client = AsyncLDClient(sdk_config) |
| 100 | + # The async client accepts AsyncHook instances only; register the |
| 101 | + # harness's async posting hooks via add_hook() before start(). |
| 102 | + for hook in hooks: |
| 103 | + self._client.add_hook(hook) |
| 104 | + await self._client.start(start_wait / 1000.0) |
| 105 | + self._listeners = AsyncListenerRegistry(self._client.flag_tracker) |
| 106 | + |
| 107 | + def is_initializing(self) -> bool: |
| 108 | + return self._client.is_initialized() if self._client else False |
| 109 | + |
| 110 | + async def evaluate(self, params: dict) -> dict: |
| 111 | + response = {} |
| 112 | + if params.get("detail", False): |
| 113 | + detail = await self._client.variation_detail( |
| 114 | + params["flagKey"], Context.from_dict(params["context"]), params["defaultValue"] |
| 115 | + ) |
| 116 | + response["value"] = detail.value |
| 117 | + response["variationIndex"] = detail.variation_index |
| 118 | + response["reason"] = detail.reason |
| 119 | + else: |
| 120 | + response["value"] = await self._client.variation( |
| 121 | + params["flagKey"], Context.from_dict(params["context"]), params["defaultValue"] |
| 122 | + ) |
| 123 | + return response |
| 124 | + |
| 125 | + async def evaluate_all(self, params: dict) -> dict: |
| 126 | + opts = {} |
| 127 | + opts["client_side_only"] = params.get("clientSideOnly", False) |
| 128 | + opts["with_reasons"] = params.get("withReasons", False) |
| 129 | + opts["details_only_for_tracked_flags"] = params.get("detailsOnlyForTrackedFlags", False) |
| 130 | + state = await self._client.all_flags_state(Context.from_dict(params["context"]), **opts) |
| 131 | + return {"state": state.to_json_dict()} |
| 132 | + |
| 133 | + def track(self, params: dict): |
| 134 | + self._client.track( |
| 135 | + params["eventKey"], |
| 136 | + Context.from_dict(params["context"]), |
| 137 | + params["data"], |
| 138 | + params.get("metricValue", None), |
| 139 | + ) |
| 140 | + |
| 141 | + def identify(self, params: dict): |
| 142 | + self._client.identify(Context.from_dict(params["context"])) |
| 143 | + |
| 144 | + async def flush(self): |
| 145 | + await self._client.flush() |
| 146 | + |
| 147 | + def secure_mode_hash(self, params: dict) -> dict: |
| 148 | + return {"result": self._client.secure_mode_hash(Context.from_dict(params["context"]))} |
| 149 | + |
| 150 | + def context_build(self, params: dict) -> dict: |
| 151 | + if params.get("multi"): |
| 152 | + b = Context.multi_builder() |
| 153 | + for c in params.get("multi"): |
| 154 | + b.add(self._context_build_single(c)) |
| 155 | + return self._context_response(b.build()) |
| 156 | + return self._context_response(self._context_build_single(params["single"])) |
| 157 | + |
| 158 | + def _context_build_single(self, params: dict) -> Context: |
| 159 | + b = Context.builder(params["key"]) |
| 160 | + if "kind" in params: |
| 161 | + b.kind(params["kind"]) |
| 162 | + if "name" in params: |
| 163 | + b.name(params["name"]) |
| 164 | + if "anonymous" in params: |
| 165 | + b.anonymous(params["anonymous"]) |
| 166 | + if "custom" in params: |
| 167 | + for k, v in params.get("custom").items(): |
| 168 | + b.set(k, v) |
| 169 | + if "private" in params: |
| 170 | + for attr in params.get("private"): |
| 171 | + b.private(attr) |
| 172 | + return b.build() |
| 173 | + |
| 174 | + def context_convert(self, params: dict) -> dict: |
| 175 | + input_str = params["input"] |
| 176 | + try: |
| 177 | + props = json.loads(input_str) |
| 178 | + return self._context_response(Context.from_dict(props)) |
| 179 | + except Exception as e: |
| 180 | + return {"error": str(e)} |
| 181 | + |
| 182 | + def _context_response(self, c: Context) -> dict: |
| 183 | + if c.valid: |
| 184 | + return {"output": c.to_json_string()} |
| 185 | + return {"error": c.error} |
| 186 | + |
| 187 | + async def get_big_segment_store_status(self) -> dict: |
| 188 | + status = self._client.big_segment_store_status_provider.status |
| 189 | + return {"available": status.available, "stale": status.stale} |
| 190 | + |
| 191 | + async def migration_variation(self, params: dict) -> dict: |
| 192 | + stage, _ = await self._client.migration_variation( |
| 193 | + params["key"], Context.from_dict(params["context"]), Stage.from_str(params["defaultStage"]) |
| 194 | + ) |
| 195 | + return {'result': stage.value} |
| 196 | + |
| 197 | + async def migration_operation(self, params: dict) -> dict: |
| 198 | + # Exercises the real AsyncMigratorBuilder/AsyncMigrator abstraction. The |
| 199 | + # user read/write callbacks are async functions that run the blocking |
| 200 | + # requests.post off the event loop via asyncio.to_thread. |
| 201 | + if params["readExecutionOrder"] == "concurrent": |
| 202 | + params["readExecutionOrder"] = "parallel" |
| 203 | + |
| 204 | + def callback(endpoint): |
| 205 | + async def fn(payload) -> Result: |
| 206 | + def do_post() -> Result: |
| 207 | + response = requests.post(endpoint, data=payload) |
| 208 | + if response.status_code == 200: |
| 209 | + return Result.success(response.text) |
| 210 | + return Result.fail(f"Request failed with status code {response.status_code}") |
| 211 | + |
| 212 | + return await asyncio.to_thread(do_post) |
| 213 | + |
| 214 | + return fn |
| 215 | + |
| 216 | + builder = AsyncMigratorBuilder(self._client) |
| 217 | + builder.read_execution_order(ExecutionOrder.from_str(params["readExecutionOrder"])) |
| 218 | + builder.track_latency(params["trackLatency"]) |
| 219 | + builder.track_errors(params["trackErrors"]) |
| 220 | + |
| 221 | + comparison = (lambda lhs, rhs: lhs == rhs) if params["trackConsistency"] else None |
| 222 | + builder.read(callback(params["oldEndpoint"]), callback(params["newEndpoint"]), comparison) |
| 223 | + builder.write(callback(params["oldEndpoint"]), callback(params["newEndpoint"])) |
| 224 | + |
| 225 | + migrator = builder.build() |
| 226 | + if isinstance(migrator, str): |
| 227 | + return {"result": migrator} |
| 228 | + |
| 229 | + key = params["key"] |
| 230 | + context = Context.from_dict(params["context"]) |
| 231 | + default_stage = Stage.from_str(params["defaultStage"]) |
| 232 | + payload = params["payload"] |
| 233 | + |
| 234 | + if params["operation"] == Operation.READ.value: |
| 235 | + result = await migrator.read(key, context, default_stage, payload) |
| 236 | + return {"result": result.value if result.is_success() else result.error} |
| 237 | + |
| 238 | + write_result = await migrator.write(key, context, default_stage, payload) |
| 239 | + authoritative = write_result.authoritative |
| 240 | + return {"result": authoritative.value if authoritative.is_success() else authoritative.error} |
| 241 | + |
| 242 | + async def register_flag_change_listener(self, params: dict): |
| 243 | + await self._listeners.register_flag_change_listener( |
| 244 | + listener_id=params['listenerId'], |
| 245 | + callback_uri=params['callbackUri'], |
| 246 | + ) |
| 247 | + |
| 248 | + async def register_flag_value_change_listener(self, params: dict): |
| 249 | + await self._listeners.register_flag_value_change_listener( |
| 250 | + listener_id=params["listenerId"], |
| 251 | + flag_key=params["flagKey"], |
| 252 | + context=Context.from_dict(params["context"]), |
| 253 | + callback_uri=params["callbackUri"], |
| 254 | + ) |
| 255 | + |
| 256 | + async def unregister_listener(self, params: dict) -> bool: |
| 257 | + return await self._listeners.unregister(params['listenerId']) |
| 258 | + |
| 259 | + async def close(self): |
| 260 | + if self._listeners is not None: |
| 261 | + await self._listeners.close_all() |
| 262 | + if self._client is not None: |
| 263 | + await self._client.close() |
| 264 | + self.log.info('Test ended') |
| 265 | + |
| 266 | + |
| 267 | +def _set_optional_time_prop(params_in: dict, name_in: str, params_out: dict, name_out: str): |
| 268 | + if params_in.get(name_in) is not None: |
| 269 | + params_out[name_out] = params_in[name_in] / 1000.0 |
0 commit comments