krons 0.1.0__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 (101) hide show
  1. kronos/__init__.py +0 -0
  2. kronos/core/__init__.py +145 -0
  3. kronos/core/broadcaster.py +116 -0
  4. kronos/core/element.py +225 -0
  5. kronos/core/event.py +316 -0
  6. kronos/core/eventbus.py +116 -0
  7. kronos/core/flow.py +356 -0
  8. kronos/core/graph.py +442 -0
  9. kronos/core/node.py +982 -0
  10. kronos/core/pile.py +575 -0
  11. kronos/core/processor.py +494 -0
  12. kronos/core/progression.py +296 -0
  13. kronos/enforcement/__init__.py +57 -0
  14. kronos/enforcement/common/__init__.py +34 -0
  15. kronos/enforcement/common/boolean.py +85 -0
  16. kronos/enforcement/common/choice.py +97 -0
  17. kronos/enforcement/common/mapping.py +118 -0
  18. kronos/enforcement/common/model.py +102 -0
  19. kronos/enforcement/common/number.py +98 -0
  20. kronos/enforcement/common/string.py +140 -0
  21. kronos/enforcement/context.py +129 -0
  22. kronos/enforcement/policy.py +80 -0
  23. kronos/enforcement/registry.py +153 -0
  24. kronos/enforcement/rule.py +312 -0
  25. kronos/enforcement/service.py +370 -0
  26. kronos/enforcement/validator.py +198 -0
  27. kronos/errors.py +146 -0
  28. kronos/operations/__init__.py +32 -0
  29. kronos/operations/builder.py +228 -0
  30. kronos/operations/flow.py +398 -0
  31. kronos/operations/node.py +101 -0
  32. kronos/operations/registry.py +92 -0
  33. kronos/protocols.py +414 -0
  34. kronos/py.typed +0 -0
  35. kronos/services/__init__.py +81 -0
  36. kronos/services/backend.py +286 -0
  37. kronos/services/endpoint.py +608 -0
  38. kronos/services/hook.py +471 -0
  39. kronos/services/imodel.py +465 -0
  40. kronos/services/registry.py +115 -0
  41. kronos/services/utilities/__init__.py +36 -0
  42. kronos/services/utilities/header_factory.py +87 -0
  43. kronos/services/utilities/rate_limited_executor.py +271 -0
  44. kronos/services/utilities/rate_limiter.py +180 -0
  45. kronos/services/utilities/resilience.py +414 -0
  46. kronos/session/__init__.py +41 -0
  47. kronos/session/exchange.py +258 -0
  48. kronos/session/message.py +60 -0
  49. kronos/session/session.py +411 -0
  50. kronos/specs/__init__.py +25 -0
  51. kronos/specs/adapters/__init__.py +0 -0
  52. kronos/specs/adapters/_utils.py +45 -0
  53. kronos/specs/adapters/dataclass_field.py +246 -0
  54. kronos/specs/adapters/factory.py +56 -0
  55. kronos/specs/adapters/pydantic_adapter.py +309 -0
  56. kronos/specs/adapters/sql_ddl.py +946 -0
  57. kronos/specs/catalog/__init__.py +36 -0
  58. kronos/specs/catalog/_audit.py +39 -0
  59. kronos/specs/catalog/_common.py +43 -0
  60. kronos/specs/catalog/_content.py +59 -0
  61. kronos/specs/catalog/_enforcement.py +70 -0
  62. kronos/specs/factory.py +120 -0
  63. kronos/specs/operable.py +314 -0
  64. kronos/specs/phrase.py +405 -0
  65. kronos/specs/protocol.py +140 -0
  66. kronos/specs/spec.py +506 -0
  67. kronos/types/__init__.py +60 -0
  68. kronos/types/_sentinel.py +311 -0
  69. kronos/types/base.py +369 -0
  70. kronos/types/db_types.py +260 -0
  71. kronos/types/identity.py +66 -0
  72. kronos/utils/__init__.py +40 -0
  73. kronos/utils/_hash.py +234 -0
  74. kronos/utils/_json_dump.py +392 -0
  75. kronos/utils/_lazy_init.py +63 -0
  76. kronos/utils/_to_list.py +165 -0
  77. kronos/utils/_to_num.py +85 -0
  78. kronos/utils/_utils.py +375 -0
  79. kronos/utils/concurrency/__init__.py +205 -0
  80. kronos/utils/concurrency/_async_call.py +333 -0
  81. kronos/utils/concurrency/_cancel.py +122 -0
  82. kronos/utils/concurrency/_errors.py +96 -0
  83. kronos/utils/concurrency/_patterns.py +363 -0
  84. kronos/utils/concurrency/_primitives.py +328 -0
  85. kronos/utils/concurrency/_priority_queue.py +135 -0
  86. kronos/utils/concurrency/_resource_tracker.py +110 -0
  87. kronos/utils/concurrency/_run_async.py +67 -0
  88. kronos/utils/concurrency/_task.py +95 -0
  89. kronos/utils/concurrency/_utils.py +79 -0
  90. kronos/utils/fuzzy/__init__.py +14 -0
  91. kronos/utils/fuzzy/_extract_json.py +90 -0
  92. kronos/utils/fuzzy/_fuzzy_json.py +288 -0
  93. kronos/utils/fuzzy/_fuzzy_match.py +149 -0
  94. kronos/utils/fuzzy/_string_similarity.py +187 -0
  95. kronos/utils/fuzzy/_to_dict.py +396 -0
  96. kronos/utils/sql/__init__.py +13 -0
  97. kronos/utils/sql/_sql_validation.py +142 -0
  98. krons-0.1.0.dist-info/METADATA +70 -0
  99. krons-0.1.0.dist-info/RECORD +101 -0
  100. krons-0.1.0.dist-info/WHEEL +4 -0
  101. krons-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,465 @@
1
+ # Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from pydantic import Field, field_serializer, field_validator
9
+
10
+ from kronos.core import Element, Executor
11
+ from kronos.protocols import Invocable, implements
12
+ from kronos.utils.concurrency import sleep
13
+
14
+ from .backend import ServiceBackend
15
+ from .endpoint import Endpoint
16
+ from .hook import HookRegistry
17
+ from .utilities.rate_limited_executor import RateLimitedExecutor
18
+ from .utilities.rate_limiter import RateLimitConfig, TokenBucket
19
+
20
+ if TYPE_CHECKING:
21
+ from .backend import Calling
22
+
23
+
24
+ __all__ = ("iModel",)
25
+
26
+
27
+ @implements(Invocable)
28
+ class iModel(Element): # noqa: N801
29
+ """Unified service interface wrapping ServiceBackend with rate limiting and hooks.
30
+
31
+ Combines ServiceBackend (API abstraction) with optional:
32
+ - Rate limiting: TokenBucket (simple) or Executor (event-driven)
33
+ - Hook registry: Lifecycle callbacks at PreEventCreate/PreInvocation/PostInvocation
34
+
35
+ Attributes:
36
+ backend: ServiceBackend instance (e.g., Endpoint for HTTP APIs).
37
+ rate_limiter: Optional TokenBucket for simple blocking rate limits.
38
+ executor: Optional Executor for event-driven processing with rate limiting.
39
+ hook_registry: Optional HookRegistry for invocation lifecycle callbacks.
40
+ provider_metadata: Provider-specific state (e.g., Claude Code session_id).
41
+ """
42
+
43
+ _EXECUTOR_POLL_TIMEOUT_ITERATIONS = 100
44
+ _EXECUTOR_POLL_SLEEP_INTERVAL = 0.1
45
+
46
+ backend: ServiceBackend | None = Field(
47
+ None,
48
+ description="ServiceBackend instance (e.g., Endpoint)",
49
+ )
50
+
51
+ rate_limiter: TokenBucket | None = Field(
52
+ None,
53
+ description="Optional TokenBucket rate limiter (simple blocking)",
54
+ )
55
+
56
+ executor: Executor | None = Field(
57
+ None,
58
+ description="Optional Executor for event-driven processing with rate limiting",
59
+ )
60
+
61
+ hook_registry: HookRegistry | None = Field(
62
+ None,
63
+ description="Optional HookRegistry for invocation lifecycle hooks",
64
+ )
65
+
66
+ provider_metadata: dict[str, Any] = Field(
67
+ default_factory=dict,
68
+ description="Provider-specific metadata (e.g., Claude Code session_id for context continuation)",
69
+ )
70
+
71
+ def __init__(
72
+ self,
73
+ backend: ServiceBackend,
74
+ rate_limiter: TokenBucket | None = None,
75
+ executor: Executor | None = None,
76
+ hook_registry: HookRegistry | None = None,
77
+ queue_capacity: int = 100,
78
+ capacity_refresh_time: float = 60,
79
+ limit_requests: int | None = None,
80
+ ):
81
+ """Initialize iModel with ServiceBackend.
82
+
83
+ Args:
84
+ backend: ServiceBackend instance (required).
85
+ rate_limiter: TokenBucket for simple blocking rate limits.
86
+ executor: Executor for event-driven processing.
87
+ hook_registry: HookRegistry for lifecycle callbacks.
88
+ queue_capacity: Event queue size for auto-constructed executor.
89
+ capacity_refresh_time: Seconds for rate limit bucket refill.
90
+ limit_requests: If set without executor, auto-constructs RateLimitedExecutor.
91
+ """
92
+ if executor is None and limit_requests:
93
+ request_bucket = TokenBucket(
94
+ RateLimitConfig(
95
+ capacity=limit_requests,
96
+ refill_rate=limit_requests / capacity_refresh_time,
97
+ )
98
+ )
99
+
100
+ executor = RateLimitedExecutor(
101
+ processor_config={
102
+ "queue_capacity": queue_capacity,
103
+ "capacity_refresh_time": capacity_refresh_time,
104
+ "request_bucket": request_bucket,
105
+ }
106
+ )
107
+
108
+ super().__init__(
109
+ backend=backend,
110
+ rate_limiter=rate_limiter,
111
+ executor=executor,
112
+ hook_registry=hook_registry,
113
+ )
114
+
115
+ @property
116
+ def name(self) -> str:
117
+ """Service name from backend."""
118
+ if self.backend is None:
119
+ raise RuntimeError("Backend not configured")
120
+ return self.backend.name
121
+
122
+ @property
123
+ def version(self) -> str:
124
+ """Service version from backend."""
125
+ if self.backend is None:
126
+ raise RuntimeError("Backend not configured")
127
+ return self.backend.version or ""
128
+
129
+ @property
130
+ def tags(self) -> set[str]:
131
+ """Service tags from backend."""
132
+ if self.backend is None:
133
+ raise RuntimeError("Backend not configured")
134
+ return self.backend.tags
135
+
136
+ async def create_calling(
137
+ self,
138
+ timeout: float | None = None,
139
+ streaming: bool = False,
140
+ create_event_exit_hook: bool | None = None,
141
+ create_event_hook_timeout: float = 10.0,
142
+ create_event_hook_params: dict | None = None,
143
+ pre_invoke_exit_hook: bool | None = None,
144
+ pre_invoke_hook_timeout: float = 30.0,
145
+ pre_invoke_hook_params: dict | None = None,
146
+ post_invoke_exit_hook: bool | None = None,
147
+ post_invoke_hook_timeout: float = 30.0,
148
+ post_invoke_hook_params: dict | None = None,
149
+ **arguments: Any,
150
+ ) -> Calling:
151
+ """Create Calling instance via backend.
152
+
153
+ Calls create_payload on backend to get validated payload.
154
+ Attaches hook_registry to Calling if configured.
155
+
156
+ Args:
157
+ timeout: Event timeout in seconds (enforced in Event.invoke via fail_after)
158
+ streaming: Whether this is a streaming request (Event.streaming attr)
159
+ create_event_exit_hook: Whether pre-event-create hook should trigger exit on failure (None = use default)
160
+ create_event_hook_timeout: Timeout for pre-event-create hook execution in seconds
161
+ create_event_hook_params: Optional parameters to pass to pre-event-create hook
162
+ pre_invoke_exit_hook: Whether pre-invoke hook should trigger exit on failure (None = use default)
163
+ pre_invoke_hook_timeout: Timeout for pre-invoke hook execution in seconds
164
+ pre_invoke_hook_params: Optional parameters to pass to pre-invoke hook
165
+ post_invoke_exit_hook: Whether post-invoke hook should trigger exit on failure (None = use default)
166
+ post_invoke_hook_timeout: Timeout for post-invoke hook execution in seconds
167
+ post_invoke_hook_params: Optional parameters to pass to post-invoke hook
168
+ **arguments: Request arguments to pass to backend
169
+ """
170
+ from .hook import HookEvent, HookPhase
171
+
172
+ if self.backend is None:
173
+ raise RuntimeError("Backend not configured")
174
+
175
+ calling_type = self.backend.event_type
176
+
177
+ if self.hook_registry is not None and self.hook_registry._can_handle(
178
+ hp_=HookPhase.PreEventCreate
179
+ ):
180
+ h_ev = HookEvent(
181
+ hook_phase=HookPhase.PreEventCreate,
182
+ event_like=calling_type,
183
+ registry=self.hook_registry,
184
+ exit=(create_event_exit_hook if create_event_exit_hook is not None else False),
185
+ timeout=create_event_hook_timeout,
186
+ streaming=False,
187
+ params=create_event_hook_params or {},
188
+ )
189
+ await h_ev.invoke()
190
+
191
+ if h_ev._should_exit:
192
+ raise h_ev._exit_cause or RuntimeError(
193
+ "PreEventCreate hook requested exit without a cause"
194
+ )
195
+
196
+ payload = self.backend.create_payload(request=arguments)
197
+ calling: Calling = calling_type(
198
+ backend=self.backend,
199
+ payload=payload,
200
+ timeout=timeout,
201
+ streaming=streaming,
202
+ )
203
+
204
+ if self.hook_registry is not None and self.hook_registry._can_handle(
205
+ hp_=HookPhase.PreInvocation
206
+ ):
207
+ calling.create_pre_invoke_hook(
208
+ hook_registry=self.hook_registry,
209
+ exit_hook=(pre_invoke_exit_hook if pre_invoke_exit_hook is not None else False),
210
+ hook_timeout=pre_invoke_hook_timeout,
211
+ hook_params=pre_invoke_hook_params or {},
212
+ )
213
+
214
+ if self.hook_registry is not None and self.hook_registry._can_handle(
215
+ hp_=HookPhase.PostInvocation
216
+ ):
217
+ calling.create_post_invoke_hook(
218
+ hook_registry=self.hook_registry,
219
+ exit_hook=(post_invoke_exit_hook if post_invoke_exit_hook is not None else False),
220
+ hook_timeout=post_invoke_hook_timeout,
221
+ hook_params=post_invoke_hook_params or {},
222
+ )
223
+
224
+ if timeout is not None:
225
+ calling.timeout = timeout
226
+ if streaming:
227
+ calling.streaming = streaming
228
+
229
+ return calling
230
+
231
+ async def invoke(
232
+ self,
233
+ calling: Calling | None = None,
234
+ poll_timeout: float | None = None,
235
+ poll_interval: float | None = None,
236
+ **arguments: Any,
237
+ ) -> Calling:
238
+ """Invoke calling with optional event-driven processing.
239
+
240
+ Routes invocation based on executor presence:
241
+ - If executor configured: event-driven processing with rate limiting (lionagi v0 pattern)
242
+ - Otherwise: direct invocation with optional simple rate limiting
243
+
244
+ Hooks are handled by Calling itself during invocation.
245
+
246
+ Args:
247
+ calling: Pre-created Calling instance. If provided, **arguments are IGNORED
248
+ and the calling is invoked directly. Use this when you need to configure
249
+ the Calling beforehand (e.g., set timeout on the Event).
250
+ poll_timeout: Max seconds to wait for executor completion (default: 10s).
251
+ For long-running LLM calls, increase this (e.g., 120s for large models).
252
+ poll_interval: Seconds between status checks (default: 0.1s).
253
+ **arguments: Request arguments passed to create_calling. IGNORED if calling provided.
254
+
255
+ Returns:
256
+ Calling instance with execution results populated
257
+
258
+ Raises:
259
+ TimeoutError: If rate limit acquisition or polling times out
260
+ RuntimeError: If event aborted after 3 permission denials (executor path)
261
+
262
+ Example:
263
+ # Standard usage - create and invoke in one call
264
+ calling = await imodel.invoke(model="gpt-4", messages=[...])
265
+
266
+ # Pre-created calling with custom timeout
267
+ calling = await imodel.create_calling(model="gpt-4", messages=[...])
268
+ calling.timeout = 120.0 # 2 minute timeout
269
+ calling = await imodel.invoke(calling=calling)
270
+ """
271
+ if calling is None:
272
+ calling = await self.create_calling(**arguments)
273
+
274
+ if self.executor:
275
+ if self.executor.processor is None or self.executor.processor.is_stopped():
276
+ await self.executor.start()
277
+
278
+ await self.executor.append(calling)
279
+ await self.executor.forward()
280
+
281
+ # Poll for completion (fast backends see ~100-200% overhead, slow backends <10%)
282
+ interval = poll_interval or self._EXECUTOR_POLL_SLEEP_INTERVAL
283
+ timeout_seconds = poll_timeout or (
284
+ self._EXECUTOR_POLL_TIMEOUT_ITERATIONS * self._EXECUTOR_POLL_SLEEP_INTERVAL
285
+ )
286
+ max_iterations = int(timeout_seconds / interval)
287
+ ctr = 0
288
+
289
+ while calling.execution.status.value in ["pending", "processing"]:
290
+ if ctr > max_iterations:
291
+ raise TimeoutError(
292
+ f"Event processing timeout after {timeout_seconds:.1f}s: {calling.id}"
293
+ )
294
+ await self.executor.forward()
295
+ ctr += 1
296
+ await sleep(interval)
297
+
298
+ if calling.execution.status.value == "aborted":
299
+ raise RuntimeError(
300
+ f"Event aborted after 3 permission denials (rate limited): {calling.id}"
301
+ )
302
+ elif calling.execution.status.value == "failed":
303
+ raise calling.execution.error or RuntimeError(f"Event failed: {calling.id}")
304
+
305
+ self._store_claude_code_session_id(calling)
306
+ return calling
307
+
308
+ else:
309
+ if self.rate_limiter:
310
+ acquired = await self.rate_limiter.acquire(timeout=30.0)
311
+ if not acquired:
312
+ raise TimeoutError("Rate limit acquisition timeout (30s)")
313
+
314
+ await calling.invoke()
315
+ self._store_claude_code_session_id(calling)
316
+ return calling
317
+
318
+ def _store_claude_code_session_id(self, calling: Calling) -> None:
319
+ """Extract and store Claude Code session_id for context continuation."""
320
+ from kronos.types import is_sentinel
321
+
322
+ from .backend import NormalizedResponse
323
+
324
+ if (
325
+ isinstance(self.backend, Endpoint)
326
+ and self.backend.config.provider == "claude_code"
327
+ and not is_sentinel(calling.execution.response)
328
+ ):
329
+ response = calling.execution.response
330
+ # session_id is in response metadata
331
+ if isinstance(response, NormalizedResponse) and response.metadata:
332
+ session_id = response.metadata.get("session_id")
333
+ if session_id:
334
+ self.provider_metadata["session_id"] = session_id
335
+
336
+ @field_serializer("backend")
337
+ def _serialize_backend(self, backend: ServiceBackend) -> dict[str, Any] | None:
338
+ """Serialize backend to dict with kron_class for polymorphic restoration."""
339
+ if backend is None:
340
+ return None
341
+ backend_dict = backend.model_dump()
342
+ if "metadata" not in backend_dict:
343
+ backend_dict["metadata"] = {}
344
+ backend_dict["metadata"]["kron_class"] = backend.__class__.class_name(full=True)
345
+ return backend_dict
346
+
347
+ @field_serializer("rate_limiter")
348
+ def _serialize_rate_limiter(self, v: TokenBucket | None) -> dict[str, Any] | None:
349
+ if v is None:
350
+ return None
351
+ return v.to_dict()
352
+
353
+ @field_serializer("executor")
354
+ def _serialize_executor(self, executor: Executor | None) -> dict[str, Any] | None:
355
+ """Serialize executor config (ephemeral state lost, fresh capacity on restore)."""
356
+ if executor is None:
357
+ return None
358
+
359
+ if isinstance(executor, RateLimitedExecutor):
360
+ config = {**executor.processor_config}
361
+ if "request_bucket" in config and config["request_bucket"] is not None:
362
+ bucket = config["request_bucket"]
363
+ if isinstance(bucket, TokenBucket):
364
+ config["request_bucket"] = bucket.to_dict()
365
+ return config
366
+
367
+ return None
368
+
369
+ @field_validator("rate_limiter", mode="before")
370
+ @classmethod
371
+ def _deserialize_rate_limiter(cls, v: Any) -> TokenBucket | None:
372
+ """Reconstruct TokenBucket from RateLimitConfig dict."""
373
+ if v is None:
374
+ return None
375
+
376
+ if isinstance(v, TokenBucket):
377
+ return v
378
+
379
+ if not isinstance(v, dict):
380
+ raise ValueError("rate_limiter must be a dict or TokenBucket instance")
381
+
382
+ config = RateLimitConfig(**v)
383
+ return TokenBucket(config)
384
+
385
+ @field_validator("backend", mode="before")
386
+ @classmethod
387
+ def _deserialize_backend(cls, v: Any) -> ServiceBackend:
388
+ """Reconstruct backend via Element polymorphic deserialization."""
389
+ if v is None:
390
+ raise ValueError("backend is required")
391
+
392
+ if isinstance(v, ServiceBackend):
393
+ return v
394
+
395
+ if not isinstance(v, dict):
396
+ raise ValueError("backend must be a dict or ServiceBackend instance")
397
+
398
+ from kronos.core import Element
399
+
400
+ backend = Element.from_dict(v)
401
+
402
+ if not isinstance(backend, ServiceBackend):
403
+ raise ValueError(
404
+ f"Deserialized backend must be ServiceBackend subclass, got: {type(backend).__name__}"
405
+ )
406
+ return backend
407
+
408
+ @field_validator("executor", mode="before")
409
+ @classmethod
410
+ def _deserialize_executor(cls, v: Any) -> Executor | None:
411
+ """Reconstruct executor from config dict (TokenBuckets get fresh capacity)."""
412
+ if v is None:
413
+ return None
414
+
415
+ if isinstance(v, Executor):
416
+ return v
417
+
418
+ if not isinstance(v, dict):
419
+ raise ValueError("executor must be a dict or Executor instance")
420
+
421
+ config = {**v}
422
+ if "request_bucket" in config and isinstance(config["request_bucket"], dict):
423
+ config["request_bucket"] = TokenBucket(RateLimitConfig(**config["request_bucket"]))
424
+ if "token_bucket" in config and isinstance(config["token_bucket"], dict):
425
+ config["token_bucket"] = TokenBucket(RateLimitConfig(**config["token_bucket"]))
426
+
427
+ return RateLimitedExecutor(processor_config=config)
428
+
429
+ def _to_dict(self, **kwargs: Any) -> dict[str, Any]:
430
+ """Serialize to dict, excluding id/created_at for fresh identity on reconstruction."""
431
+ kwargs.setdefault("exclude", set()).update({"id", "created_at"})
432
+ return super()._to_dict(**kwargs)
433
+
434
+ def __repr__(self) -> str:
435
+ """String representation."""
436
+ if self.backend is None:
437
+ return "iModel(backend=None)"
438
+ return f"iModel(backend={self.backend.name}, version={self.backend.version})"
439
+
440
+ async def __aenter__(self) -> iModel:
441
+ """Enter async context, starting executor if configured."""
442
+ if self.executor is not None and (
443
+ self.executor.processor is None or self.executor.processor.is_stopped()
444
+ ):
445
+ await self.executor.start()
446
+ return self
447
+
448
+ async def __aexit__(
449
+ self,
450
+ _exc_type: type[BaseException] | None,
451
+ _exc_val: BaseException | None,
452
+ _exc_tb: Any,
453
+ ) -> bool:
454
+ """Exit async context, stopping executor if running.
455
+
456
+ Returns:
457
+ False to propagate any exceptions (never suppresses)
458
+ """
459
+ if (
460
+ self.executor is not None
461
+ and self.executor.processor is not None
462
+ and not self.executor.processor.is_stopped()
463
+ ):
464
+ await self.executor.stop()
465
+ return False
@@ -0,0 +1,115 @@
1
+ # Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Any
7
+ from uuid import UUID
8
+
9
+ from kronos.core import Pile
10
+ from kronos.types import Undefined, UndefinedType, is_sentinel
11
+
12
+ from .imodel import iModel
13
+
14
+ __all__ = ("ServiceRegistry",)
15
+
16
+
17
+ class ServiceRegistry:
18
+ """Service registry managing iModel instances with O(1) name-based lookup.
19
+
20
+ Provides type-safe storage via Pile[iModel] with name-based indexing.
21
+ Services must have unique names; duplicates raise ValueError unless update=True.
22
+
23
+ Example:
24
+ >>> registry = ServiceRegistry()
25
+ >>> registry.register(iModel(backend=my_endpoint))
26
+ >>> model = registry.get("my_service")
27
+ >>> tagged = registry.list_by_tag("api")
28
+ """
29
+
30
+ def __init__(self):
31
+ """Initialize empty registry with Pile storage and name index."""
32
+ from .imodel import iModel
33
+
34
+ self._pile: Pile[iModel] = Pile(item_type=iModel)
35
+ self._name_index: dict[str, UUID] = {}
36
+
37
+ def register(self, model: iModel, update: bool = False) -> UUID:
38
+ """Register iModel instance by name.
39
+
40
+ Args:
41
+ model: iModel instance to register.
42
+ update: If True, replaces existing service with same name.
43
+
44
+ Returns:
45
+ UUID of registered service.
46
+
47
+ Raises:
48
+ ValueError: If service name exists and update=False.
49
+ """
50
+ if model.name in self._name_index:
51
+ if not update:
52
+ raise ValueError(f"Service '{model.name}' already registered")
53
+ # Update: remove old, add new
54
+ old_uid = self._name_index[model.name]
55
+ self._pile.remove(old_uid)
56
+
57
+ self._pile.add(model)
58
+ self._name_index[model.name] = model.id
59
+
60
+ return model.id
61
+
62
+ def unregister(self, name: str) -> iModel:
63
+ """Remove and return service by name. Raises KeyError if not found."""
64
+ if name not in self._name_index:
65
+ raise KeyError(f"Service '{name}' not found")
66
+
67
+ uid = self._name_index.pop(name)
68
+ return self._pile.remove(uid)
69
+
70
+ def get(self, name: str | UUID | iModel, default: Any | UndefinedType = Undefined) -> iModel:
71
+ """Get service by name, UUID, or return iModel passthrough. Raises KeyError if not found."""
72
+ if isinstance(name, UUID):
73
+ return self._pile[name]
74
+ if isinstance(name, iModel):
75
+ return name
76
+ if name not in self._name_index:
77
+ if not is_sentinel(default):
78
+ return default
79
+ raise KeyError(f"Service '{name}' not found")
80
+
81
+ uid = self._name_index[name]
82
+ return self._pile[uid]
83
+
84
+ def has(self, name: str) -> bool:
85
+ """Check if service exists."""
86
+ return name in self._name_index
87
+
88
+ def list_names(self) -> list[str]:
89
+ """List all registered service names."""
90
+ return list(self._name_index.keys())
91
+
92
+ def list_by_tag(self, tag: str) -> Pile[iModel]:
93
+ """Filter services by tag, returns Pile of matching iModels."""
94
+ return self._pile[lambda m: tag in m.tags]
95
+
96
+ def count(self) -> int:
97
+ """Count registered services."""
98
+ return len(self._pile)
99
+
100
+ def clear(self) -> None:
101
+ """Remove all registered services."""
102
+ self._pile.clear()
103
+ self._name_index.clear()
104
+
105
+ def __len__(self) -> int:
106
+ """Return number of registered services."""
107
+ return len(self._pile)
108
+
109
+ def __contains__(self, name: str) -> bool:
110
+ """Check if service exists (supports `name in registry`)."""
111
+ return name in self._name_index
112
+
113
+ def __repr__(self) -> str:
114
+ """String representation."""
115
+ return f"ServiceRegistry(count={len(self)})"
@@ -0,0 +1,36 @@
1
+ # Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Service utilities: rate limiting and resilience patterns.
5
+
6
+ Exports:
7
+ Rate limiting:
8
+ - RateLimitConfig: Token bucket configuration
9
+ - TokenBucket: Rate limiter with continuous refill
10
+
11
+ Resilience:
12
+ - CircuitBreaker: Fail-fast with state machine (CLOSED/OPEN/HALF_OPEN)
13
+ - CircuitBreakerOpenError: Raised when circuit is open
14
+ - CircuitState: Circuit state enum
15
+ - RetryConfig: Retry policy configuration
16
+ - retry_with_backoff: Async retry with exponential backoff + jitter
17
+ """
18
+
19
+ from .rate_limiter import RateLimitConfig, TokenBucket
20
+ from .resilience import (
21
+ CircuitBreaker,
22
+ CircuitBreakerOpenError,
23
+ CircuitState,
24
+ RetryConfig,
25
+ retry_with_backoff,
26
+ )
27
+
28
+ __all__ = (
29
+ "CircuitBreaker",
30
+ "CircuitBreakerOpenError",
31
+ "CircuitState",
32
+ "RateLimitConfig",
33
+ "RetryConfig",
34
+ "TokenBucket",
35
+ "retry_with_backoff",
36
+ )