polaris-python 0.0.1.dev3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. polaris/__init__.py +74 -0
  2. polaris/_version.py +16 -0
  3. polaris/api/__init__.py +55 -0
  4. polaris/api/_log_file.py +232 -0
  5. polaris/api/config.py +145 -0
  6. polaris/api/consumer.py +240 -0
  7. polaris/api/log.py +414 -0
  8. polaris/api/lossless.py +73 -0
  9. polaris/api/provider.py +152 -0
  10. polaris/api/skill.py +162 -0
  11. polaris/pkg/__init__.py +15 -0
  12. polaris/pkg/common/__init__.py +15 -0
  13. polaris/pkg/common/plugin.py +33 -0
  14. polaris/pkg/config/__init__.py +15 -0
  15. polaris/pkg/config/api.py +1205 -0
  16. polaris/pkg/config/default.py +92 -0
  17. polaris/pkg/config/event.py +76 -0
  18. polaris/pkg/config/loader.py +392 -0
  19. polaris/pkg/config/stat_reporter.py +100 -0
  20. polaris/pkg/flow/__init__.py +15 -0
  21. polaris/pkg/flow/admin_server.py +104 -0
  22. polaris/pkg/flow/async_flow.py +55 -0
  23. polaris/pkg/flow/base_flow.py +111 -0
  24. polaris/pkg/flow/impl.py +1277 -0
  25. polaris/pkg/flow/lossless.py +605 -0
  26. polaris/pkg/flow/notify.py +46 -0
  27. polaris/pkg/flow/register.py +210 -0
  28. polaris/pkg/flow/skill.py +110 -0
  29. polaris/pkg/flow/sync_flow.py +20 -0
  30. polaris/pkg/model/__init__.py +15 -0
  31. polaris/pkg/model/context.py +53 -0
  32. polaris/pkg/model/contract.py +89 -0
  33. polaris/pkg/model/core.py +51 -0
  34. polaris/pkg/model/engine.py +83 -0
  35. polaris/pkg/model/error.py +94 -0
  36. polaris/pkg/model/event.py +159 -0
  37. polaris/pkg/model/lossless.py +102 -0
  38. polaris/pkg/model/rule.py +152 -0
  39. polaris/pkg/model/server.py +288 -0
  40. polaris/pkg/model/service.py +820 -0
  41. polaris/pkg/model/skill.py +179 -0
  42. polaris/pkg/model/stat.py +168 -0
  43. polaris/pkg/model/util.py +58 -0
  44. polaris/pkg/version/__init__.py +18 -0
  45. polaris/plugin/__init__.py +32 -0
  46. polaris/plugin/circuit_breaker/__init__.py +19 -0
  47. polaris/plugin/circuit_breaker/base.py +54 -0
  48. polaris/plugin/circuit_breaker/composite.py +265 -0
  49. polaris/plugin/circuit_breaker/config.py +36 -0
  50. polaris/plugin/event/__init__.py +21 -0
  51. polaris/plugin/event/pushgateway.py +202 -0
  52. polaris/plugin/load_balancer/__init__.py +24 -0
  53. polaris/plugin/load_balancer/base.py +30 -0
  54. polaris/plugin/load_balancer/random.py +39 -0
  55. polaris/plugin/load_balancer/weighted_random.py +50 -0
  56. polaris/plugin/local_registry/__init__.py +18 -0
  57. polaris/plugin/local_registry/base.py +47 -0
  58. polaris/plugin/local_registry/file_cache.py +335 -0
  59. polaris/plugin/local_registry/local_cache.py +433 -0
  60. polaris/plugin/server_connector/__init__.py +20 -0
  61. polaris/plugin/server_connector/base.py +104 -0
  62. polaris/plugin/server_connector/convert.py +149 -0
  63. polaris/plugin/server_connector/grpc_connector/__init__.py +21 -0
  64. polaris/plugin/server_connector/grpc_connector/connection.py +401 -0
  65. polaris/plugin/server_connector/grpc_connector/grpc_client.py +384 -0
  66. polaris/plugin/server_connector/grpc_connector/tls.py +45 -0
  67. polaris/plugin/service_router/__init__.py +28 -0
  68. polaris/plugin/service_router/base.py +105 -0
  69. polaris/plugin/service_router/isolated.py +35 -0
  70. polaris/plugin/service_router/metadata.py +84 -0
  71. polaris/plugin/service_router/recover.py +62 -0
  72. polaris/plugin/service_router/util.py +54 -0
  73. polaris/plugin/skill/__init__.py +16 -0
  74. polaris/plugin/skill/connector.py +127 -0
  75. polaris/plugin/skill/persistence.py +94 -0
  76. polaris/plugin/stat_reporter/__init__.py +16 -0
  77. polaris/plugin/stat_reporter/base.py +34 -0
  78. polaris/plugin/stat_reporter/prometheus.py +375 -0
  79. polaris/plugin_manager.py +146 -0
  80. polaris_python-0.0.1.dev3.dist-info/METADATA +213 -0
  81. polaris_python-0.0.1.dev3.dist-info/RECORD +84 -0
  82. polaris_python-0.0.1.dev3.dist-info/WHEEL +5 -0
  83. polaris_python-0.0.1.dev3.dist-info/licenses/LICENSE +18 -0
  84. polaris_python-0.0.1.dev3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,375 @@
1
+ # Tencent is pleased to support the open source community by making polaris-python available.
2
+ #
3
+ # Copyright (C) 2021 Tencent. All rights reserved.
4
+ #
5
+ # Licensed under the BSD 3-Clause License (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # https://opensource.org/licenses/BSD-3-Clause
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software distributed
12
+ # under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
13
+ # CONDITIONS OF ANY KIND, either express or implied. See the License for the
14
+ # specific language governing permissions and limitations under the License.
15
+
16
+ """Java-compatible period gauges and Prometheus pull / Pushgateway export."""
17
+
18
+ import asyncio
19
+ import gzip
20
+ import random
21
+ import re
22
+ import time
23
+ from threading import RLock
24
+ from urllib.parse import quote, urlsplit, urlunsplit
25
+
26
+ from aiohttp import ClientSession, ClientTimeout, web
27
+ from prometheus_client import CollectorRegistry, Gauge, generate_latest
28
+ from prometheus_client.exposition import CONTENT_TYPE_LATEST
29
+
30
+ from polaris.pkg.model.error import ErrorCode, SDKError
31
+ from polaris.pkg.config import stat_reporter as stat_reporter_config
32
+ from polaris.pkg.model.service import GetOneInstanceRequest, RetStatus
33
+ from polaris.plugin_manager import PluginType, register_plugin
34
+ from .base import BaseStatReporter
35
+
36
+ LABELS = ("callee_namespace", "callee_service", "callee_method", "callee_instance",
37
+ "callee_instance_type", "callee_result_code", "callee_result", "caller_labels",
38
+ "caller_namespace", "caller_service", "caller_ip", "metric_name")
39
+ METRICS = {
40
+ "upstream_rq_total": "total of request per period",
41
+ "upstream_rq_success": "total of success request per period",
42
+ "upstream_rq_timeout": "total of request delay per period",
43
+ "upstream_rq_max_timeout": "maximum request delay per period",
44
+ "upstream_rq_min_timeout": "minimum request delay per period",
45
+ }
46
+ PULL_INTERVAL_SECONDS = 30
47
+ REVISION_LOG_INTERVAL_SECONDS = 30 * 60
48
+ PUSH_ERROR_LOG_INTERVAL_SECONDS = 30
49
+ NULL = "__NULL__"
50
+
51
+
52
+ @register_plugin(PluginType.STAT_REPORTER, "prometheus")
53
+ class PrometheusReporter(BaseStatReporter):
54
+ """Own independent metrics, HTTP listener and push task for one context."""
55
+
56
+ def __init__(self, init_ctx):
57
+ self.global_config = init_ctx.config.get_global()
58
+ self.config = self.global_config.get_stat_reporter().get_prometheus()
59
+ self.enabled = self.global_config.get_stat_reporter().is_enable()
60
+ self.client_id = init_ctx.value_ctx.get_client_id()
61
+ from polaris.api.log import get_logger
62
+ self.logger = init_ctx.logger.get_logger("statReport") if init_ctx.logger else get_logger("statReport")
63
+ self.registry = CollectorRegistry()
64
+ self.gauges = {name: Gauge(name, description, LABELS, registry=self.registry)
65
+ for name, description in METRICS.items()}
66
+ self.lock = RLock()
67
+ self.period = 0
68
+ self.values = {}
69
+ self.task = None
70
+ self.runner = None
71
+ self._admin_server = None
72
+ self.session = None
73
+ self.push_count = 0
74
+ self._port = None
75
+ self._reported_port = None
76
+ self.engine = None
77
+ self.closed = False
78
+ self.started = False
79
+ self.random = random.Random()
80
+ self.push_lock = asyncio.Lock()
81
+ self.pushing = False
82
+ self._last_pull_revision_log = None
83
+ self._last_push_revision_log = None
84
+ self._last_push_error = ""
85
+ self._last_push_error_log = 0.0
86
+
87
+ @property
88
+ def port(self):
89
+ """Return the shared admin port when mounted, otherwise the private one."""
90
+ return self._admin_server.port if self._admin_server is not None else self._port
91
+
92
+ def report(self, result):
93
+ """Record Java label values and period count / delay aggregations."""
94
+ if not self.enabled or self.closed:
95
+ return
96
+ method = result.method
97
+ if method:
98
+ for pattern in self.config.path_regex_list:
99
+ if re.fullmatch(pattern, method):
100
+ method = pattern
101
+ break
102
+ caller = result.caller_service
103
+ ip = result.caller_ip
104
+ if ip is None:
105
+ ip = self.global_config.get_api().get_bind_ip()
106
+ labels = (result.get_namespace(), result.get_service(), method,
107
+ f"{result.get_host()}:{result.get_port()}", result.instance_type,
108
+ result.ret_code, RetStatus.descriptions[result.ret_status], result.labels,
109
+ caller.get_namespace() if caller else None,
110
+ caller.get_service() if caller else None, ip)
111
+ key = tuple(NULL if item is None else str(item) for item in labels)
112
+ delay = result.delay or 0
113
+ success = int(result.ret_status == RetStatus.RetSuccess)
114
+ with self.lock:
115
+ period = self.period + int(self.pushing)
116
+ existing = self.values.get(key)
117
+ if existing is None or existing[0] != period:
118
+ self.values[key] = (period, [1, success, delay, delay, delay])
119
+ else:
120
+ values = existing[1]
121
+ values[0] += 1
122
+ values[1] += success
123
+ if result.delay is not None:
124
+ values[2] += delay
125
+ values[3] = max(values[3], delay)
126
+ values[4] = min(values[4], delay)
127
+
128
+ def aggregate(self, advance=True):
129
+ """Publish this period, zero stale series, remove after two idle revisions."""
130
+ with self.lock:
131
+ active_series = sum(
132
+ 1 for revision, values in self.values.values()
133
+ if revision == self.period and values[0] > 0
134
+ )
135
+ request_count = sum(
136
+ values[0] for revision, values in self.values.values() if revision == self.period
137
+ )
138
+ for key, (revision, values) in list(self.values.items()):
139
+ for name, value in zip(METRICS, values):
140
+ labels = (*key, NULL)
141
+ if revision < self.period - 2:
142
+ self.gauges[name].remove(*labels)
143
+ else:
144
+ self.gauges[name].labels(*labels).set(value if revision == self.period else 0)
145
+ if revision < self.period - 2:
146
+ del self.values[key]
147
+ if advance:
148
+ self.period += 1
149
+ if not advance:
150
+ return
151
+ self.logger.debug(
152
+ "[metrics][%s] aggregate completed period=%d activeSeries=%d requests=%d retainedSeries=%d",
153
+ self.config.type, self.period, active_series, request_count, len(self.values),
154
+ )
155
+ now = time.monotonic()
156
+ if (self._last_pull_revision_log is None
157
+ or now - self._last_pull_revision_log >= REVISION_LOG_INTERVAL_SECONDS):
158
+ self.logger.info(
159
+ "[metrics][pull] revision collector inc current revision to %d", self.period,
160
+ )
161
+ self._last_pull_revision_log = now
162
+
163
+ def render(self):
164
+ """Return the current exported Prometheus text without advancing the period."""
165
+ with self.lock:
166
+ data = generate_latest(self.registry)
167
+ self.logger.debug(
168
+ "[metrics][%s] render completed period=%d series=%d bytes=%d",
169
+ self.config.type, self.period, len(self.values), len(data),
170
+ )
171
+ return data
172
+
173
+ def meta_info(self):
174
+ """Advertise the pull endpoint in the SDK client report, never in push mode."""
175
+ if not self.enabled or self.closed or self.port is None or self.config.type != "pull":
176
+ return None
177
+ port = self.port
178
+ if port != self._reported_port:
179
+ self._reported_port = port
180
+ self.logger.info(
181
+ "[metrics][pull] endpoint ready port=%s path=%s", port, self.config.path,
182
+ )
183
+ return {"target": "prometheus", "port": port, "path": self.config.path, "protocol": "http"}
184
+
185
+ async def start(self, engine):
186
+ """Start pull or push resources after the SDK address is resolved."""
187
+ self.engine = engine
188
+ if not self.enabled:
189
+ self.logger.info("[metrics] reporter disabled by configuration")
190
+ return
191
+ admin = self.global_config.get_admin()
192
+ if self.config.type == "pull":
193
+ if admin.port < 0:
194
+ self.logger.warning(
195
+ "[metrics][pull] reporter disabled because global.admin.port < 0")
196
+ self.enabled = False
197
+ return
198
+ shared = getattr(engine, "admin_server", None) if engine is not None else None
199
+ if shared is not None:
200
+ shared.add_route("GET", self.config.path, self._metrics)
201
+ if self.config.path != "/-/healthy":
202
+ shared.add_route("GET", "/-/healthy", self._metrics)
203
+ self._admin_server = shared
204
+ else:
205
+ app = web.Application()
206
+ app.router.add_get(self.config.path, self._metrics)
207
+ if self.config.path != "/-/healthy":
208
+ app.router.add_get("/-/healthy", self._metrics)
209
+ self.runner = web.AppRunner(app, access_log=None)
210
+ await self.runner.setup()
211
+ listen_port = (admin.port if admin.port is not None
212
+ else stat_reporter_config.DEFAULT_ADMIN_PORT)
213
+ site = web.TCPSite(self.runner, admin.host, listen_port)
214
+ try:
215
+ await site.start()
216
+ except OSError as error:
217
+ await self.runner.cleanup()
218
+ self.runner = None
219
+ raise SDKError(ErrorCode.ErrCodeAPIInvalidConfig,
220
+ f"Cannot bind metrics listener {admin.host}:{admin.port}: {error}") from error
221
+ self._port = site._server.sockets[0].getsockname()[1]
222
+ else:
223
+ self.session = ClientSession(timeout=ClientTimeout(total=self.global_config.get_api().timeout / 1000))
224
+ self.task = asyncio.create_task(self._run(), name="polaris-prometheus")
225
+ self.started = True
226
+ interval = self.config.push_interval / 1000 if self.config.type == "push" else PULL_INTERVAL_SECONDS
227
+ port = self.port if self.port is not None else admin.port
228
+ self.logger.info(
229
+ "[metrics][%s] reporter started path=%s interval=%.3fs admin=%s:%s",
230
+ self.config.type, self.config.path, interval, admin.host, port,
231
+ )
232
+
233
+ async def _metrics(self, request):
234
+ data = self.render()
235
+ self.logger.debug(
236
+ "[metrics][pull] served path=%s port=%s series=%d bytes=%d",
237
+ request.path, self.port, len(self.values), len(data),
238
+ )
239
+ return web.Response(body=data, headers={"Content-Type": CONTENT_TYPE_LATEST})
240
+
241
+ async def _address(self):
242
+ if self.config.address:
243
+ return self.random.choice(self.config.address)
244
+ if self.config.namespace and self.config.service:
245
+ namespace = self.config.namespace
246
+ service = self.config.service
247
+ else:
248
+ monitor = self.global_config.get_system().get_monitor_cluster()
249
+ if not monitor.get_namespace() or not monitor.get_service():
250
+ raise SDKError(
251
+ ErrorCode.ErrCodeAPIInvalidConfig,
252
+ "Prometheus push requires address or namespace/service from "
253
+ "plugin.prometheus or global.system.monitorCluster",
254
+ )
255
+ namespace = monitor.get_namespace()
256
+ service = monitor.get_service()
257
+ node = await self.engine._async_get_one_instance(GetOneInstanceRequest(
258
+ namespace=namespace, service=service))
259
+ if node is None:
260
+ raise SDKError(ErrorCode.ErrCodeAPIInstanceNotFound, "No Pushgateway instance available")
261
+ host = node.get_host()
262
+ return f"[{host}]:{node.get_port()}" if ":" in host else f"{host}:{node.get_port()}"
263
+
264
+ async def push(self):
265
+ """POST gauges using Java job / grouping keys; failures retain the current period."""
266
+ async with self.push_lock:
267
+ await self._push()
268
+
269
+ async def _push(self):
270
+ address = await self._address()
271
+ url = urlsplit(address if "://" in address else f"http://{address}")
272
+ if self.config.override_host:
273
+ host = self.config.override_host
274
+ netloc = f"[{host}]" if ":" in host else host
275
+ if url.port:
276
+ netloc += f":{url.port}"
277
+ url = url._replace(netloc=netloc)
278
+ target = urlunsplit(url._replace(path=url.path.rstrip("/") +
279
+ f"/metrics/job/polaris-client/instance/{quote(self.client_id, safe='')}", query=""))
280
+ with self.lock:
281
+ self.aggregate(advance=False)
282
+ data = self.render()
283
+ snapshot = {key: (revision, list(values)) for key, (revision, values) in self.values.items()}
284
+ self.pushing = True
285
+ headers = {"Content-Type": CONTENT_TYPE_LATEST}
286
+ if self.config.open_gzip:
287
+ data = gzip.compress(data)
288
+ headers["Content-Encoding"] = "gzip"
289
+ try:
290
+ async with self.session.post(target, data=data, headers=headers) as response:
291
+ await response.read()
292
+ if not 200 <= response.status < 300:
293
+ raise SDKError(ErrorCode.ErrCodeNetworkError, f"Pushgateway returned HTTP {response.status}")
294
+ self.push_count += 1
295
+ except BaseException:
296
+ with self.lock:
297
+ for key, (revision, values) in list(self.values.items()):
298
+ if revision != self.period + 1:
299
+ continue
300
+ previous = snapshot.get(key)
301
+ if previous is not None and previous[0] == self.period:
302
+ old = previous[1]
303
+ values = [old[0] + values[0], old[1] + values[1], old[2] + values[2],
304
+ max(old[3], values[3]), min(old[4], values[4])]
305
+ self.values[key] = (self.period, values)
306
+ raise
307
+ else:
308
+ with self.lock:
309
+ self.period += 1
310
+ if self._last_push_error:
311
+ self.logger.info(
312
+ "[metrics][push] push metrics to pushgateway recovered (was failing: %s)",
313
+ self._last_push_error,
314
+ )
315
+ self._last_push_error = ""
316
+ self.logger.debug("[metrics][push] exported period=%d series=%d target=%s",
317
+ self.period, len(snapshot), target)
318
+ now = time.monotonic()
319
+ if (self._last_push_revision_log is None
320
+ or now - self._last_push_revision_log >= REVISION_LOG_INTERVAL_SECONDS):
321
+ self.logger.info(
322
+ "[metrics][push] revision collector inc current revision to %d", self.period,
323
+ )
324
+ self._last_push_revision_log = now
325
+ finally:
326
+ with self.lock:
327
+ self.pushing = False
328
+
329
+ async def _run(self):
330
+ interval = self.config.push_interval / 1000 if self.config.type == "push" else PULL_INTERVAL_SECONDS
331
+ while True:
332
+ await asyncio.sleep(interval)
333
+ try:
334
+ if self.config.type == "push":
335
+ await self.push()
336
+ else:
337
+ self.aggregate()
338
+ except Exception as error:
339
+ if self.config.type == "push":
340
+ self._log_push_error(error)
341
+ else:
342
+ self.logger.warning("Metrics export failed; retaining current period: %s", error)
343
+
344
+ def _log_push_error(self, error):
345
+ error_text = str(error)
346
+ now = time.monotonic()
347
+ if (error_text != self._last_push_error
348
+ or now - self._last_push_error_log >= PUSH_ERROR_LOG_INTERVAL_SECONDS):
349
+ if not self._last_push_error:
350
+ self.logger.error("push metrics to pushgateway fail: %s", error_text)
351
+ else:
352
+ self.logger.warning(
353
+ "push metrics to pushgateway still failing (last error unchanged for %.0fs): %s",
354
+ now - self._last_push_error_log, error_text,
355
+ )
356
+ self._last_push_error_log = now
357
+ self._last_push_error = error_text
358
+
359
+ async def close(self):
360
+ """Cancel the task before closing its listener and outgoing HTTP session."""
361
+ self.closed = True
362
+ self._reported_port = None
363
+ if self.task is not None:
364
+ self.task.cancel()
365
+ await asyncio.gather(self.task, return_exceptions=True)
366
+ self.task = None
367
+ if self.runner is not None:
368
+ await self.runner.cleanup()
369
+ self.runner = None
370
+ if self.session is not None:
371
+ await self.session.close()
372
+ self.session = None
373
+ if self.started:
374
+ self.started = False
375
+ self.logger.info("[metrics][%s] reporter stopped", self.config.type)
@@ -0,0 +1,146 @@
1
+ # Tencent is pleased to support the open source community by making polaris-python available.
2
+ #
3
+ # Copyright (C) 2021 Tencent. All rights reserved.
4
+ #
5
+ # Licensed under the BSD 3-Clause License (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # https://opensource.org/licenses/BSD-3-Clause
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software distributed
12
+ # under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
13
+ # CONDITIONS OF ANY KIND, either express or implied. See the License for the
14
+ # specific language governing permissions and limitations under the License.
15
+
16
+ from threading import RLock
17
+
18
+ from polaris.pkg.model.context import ValueContext
19
+ from polaris.pkg.config.api import Configuration
20
+ from polaris.pkg.model.error import ErrorCode, SDKError
21
+
22
+
23
+ class PluginType:
24
+ SERVER_CONNECTOR = 2
25
+ SERVICE_ROUTER = 3
26
+ LOAD_BALANCE = 4
27
+ CIRCUIT_BREAKER = 5
28
+ LOCAL_CACHE = 6
29
+ STAT_REPORTER = 7
30
+ SKILL_CONNECTOR = 8
31
+ EVENT_REPORTER = 9
32
+
33
+
34
+ class EventType:
35
+ ON_LOCALVALUE_CREATED = 1
36
+
37
+
38
+ class PluginManager:
39
+ """Own plugin instances and event subscribers for one SDK context."""
40
+
41
+ def __init__(self):
42
+ self.plugin_container = {}
43
+ self.plugin_instance = {}
44
+ self.event_handle_map = {}
45
+ self._lock = RLock()
46
+
47
+ def register(self, plugin_type: PluginType, plugin_name: str, plugin_cls):
48
+ """Register a plugin constructor without creating a runtime instance."""
49
+ with self._lock:
50
+ self.plugin_container.setdefault(plugin_type, {})[plugin_name] = plugin_cls
51
+
52
+ def new_context_manager(self):
53
+ """Copy registered constructors into an empty, independent runtime manager."""
54
+ manager = PluginManager()
55
+ with self._lock:
56
+ manager.plugin_container = {typ: dict(plugins) for typ, plugins in self.plugin_container.items()}
57
+ return manager
58
+
59
+ def get(self, plugin_type: PluginType, plugin_name: str):
60
+ """Return a registered plugin constructor."""
61
+ if plugin_type in self.plugin_container:
62
+ return self.plugin_container[plugin_type][plugin_name]
63
+ else:
64
+ return ""
65
+
66
+ def find_instance(self, plugin_type: PluginType, plugin_name: str) -> bool:
67
+ """Check whether this context has created a plugin instance."""
68
+ if plugin_type in self.plugin_instance and plugin_name in self.plugin_instance[plugin_type]:
69
+ return True
70
+ return False
71
+
72
+ def get_instance(self, plugin_type: PluginType, plugin_name: str):
73
+ """Return this context's plugin instance or a configuration diagnostic."""
74
+ try:
75
+ return self.plugin_instance[plugin_type][plugin_name]
76
+ except KeyError as error:
77
+ raise SDKError(ErrorCode.ErrCodePluginError,
78
+ f"Plugin is not available: {plugin_type}:{plugin_name}") from error
79
+
80
+ def set_instance(self, plugin_type: PluginType, plugin_name: str, instance):
81
+ """Store an instance owned exclusively by this context."""
82
+ if plugin_type not in self.plugin_instance:
83
+ self.plugin_instance[plugin_type] = {}
84
+
85
+ self.plugin_instance[plugin_type][plugin_name] = instance
86
+
87
+ def clear_instances(self):
88
+ """Forget this context's instances and their event subscribers."""
89
+ self.plugin_instance.clear()
90
+ self.event_handle_map.clear()
91
+
92
+ def destroy_plugins(self):
93
+ """Release synchronous plugin resources after the owner loop has stopped."""
94
+ instances = [instance for plugins in self.plugin_instance.values() for instance in plugins.values()]
95
+ self.clear_instances()
96
+ first_error = None
97
+ for instance in reversed(instances):
98
+ destroy = getattr(instance, "destroy", None)
99
+ if destroy is not None:
100
+ try:
101
+ destroy()
102
+ except Exception as error:
103
+ if first_error is None:
104
+ first_error = error
105
+ if first_error is not None:
106
+ raise first_error
107
+
108
+ def register_event_handle(self, event_type, handler):
109
+ """Subscribe a context-owned handler to a plugin event."""
110
+ self.event_handle_map.setdefault(event_type, []).append(handler)
111
+
112
+ def get_event_subscriber(self, event_type):
113
+ """Return only this context's subscribers, or an empty tuple."""
114
+ return tuple(self.event_handle_map.get(event_type, ()))
115
+
116
+
117
+ def register_plugin(plugin_type: PluginType, plugin_name: str):
118
+ def decorator(cls):
119
+ DefaultPluginManager.register(plugin_type, plugin_name, cls)
120
+ return cls
121
+
122
+ return decorator
123
+
124
+
125
+ # Process-wide registration stores constructors only, never SDK runtime state.
126
+ DefaultPluginManager = PluginManager()
127
+
128
+
129
+ class InitContext:
130
+ """Provide configuration and context-owned dependencies during plugin creation."""
131
+
132
+ def __init__(self, config: Configuration = None, value_ctx: ValueContext = None,
133
+ plugins: PluginManager = None):
134
+ self.config = config
135
+ self.value_ctx = value_ctx
136
+ self.plugins = plugins if plugins is not None else PluginManager()
137
+ self.logger = getattr(value_ctx, "logger", None)
138
+
139
+
140
+ def setup(init_ctx: InitContext):
141
+ """Create plugins in the supplied context manager, leaving the registry untouched."""
142
+ manager = init_ctx.plugins
143
+ for plugin_type, plugins in manager.plugin_container.items():
144
+ for plugin_name, plugin in plugins.items():
145
+ if not manager.find_instance(plugin_type, plugin_name):
146
+ manager.set_instance(plugin_type, plugin_name, plugin(init_ctx))