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,414 @@
1
+ # Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Resilience patterns: circuit breaker and retry with exponential backoff.
5
+
6
+ Provides fail-fast circuit breaking and configurable retry strategies
7
+ for transient failure handling in async operations.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import random
14
+ from collections.abc import Awaitable, Callable
15
+ from dataclasses import dataclass, field
16
+ from enum import Enum
17
+ from typing import Any, TypedDict, TypeVar
18
+
19
+ from kronos.errors import KronConnectionError
20
+ from kronos.utils.concurrency import Lock, current_time, sleep
21
+
22
+
23
+ class _MetricsDict(TypedDict):
24
+ """Internal type for circuit breaker metrics tracking."""
25
+
26
+ success_count: int
27
+ failure_count: int
28
+ rejected_count: int
29
+ state_changes: list[dict[str, Any]]
30
+
31
+
32
+ __all__ = (
33
+ "CircuitBreaker",
34
+ "CircuitBreakerOpenError",
35
+ "CircuitState",
36
+ "RetryConfig",
37
+ "retry_with_backoff",
38
+ )
39
+
40
+ T = TypeVar("T")
41
+ logger = logging.getLogger(__name__)
42
+
43
+
44
+ class CircuitBreakerOpenError(KronConnectionError):
45
+ """Circuit breaker is open."""
46
+
47
+ default_message = "Circuit breaker is open"
48
+ default_retryable = True # Circuit breaker errors are inherently retryable
49
+
50
+ def __init__(
51
+ self,
52
+ message: str | None = None,
53
+ *,
54
+ retry_after: float | None = None,
55
+ details: dict[str, Any] | None = None,
56
+ ):
57
+ """Initialize with message and optional retry_after.
58
+
59
+ Args:
60
+ message: Error message (uses default_message if None)
61
+ retry_after: Seconds until retry should be attempted
62
+ details: Additional context dict
63
+ """
64
+ # Add retry_after to details if provided
65
+ if retry_after is not None:
66
+ details = details or {}
67
+ details["retry_after"] = retry_after
68
+
69
+ super().__init__(message=message, details=details, retryable=True)
70
+ self.retry_after = retry_after
71
+
72
+
73
+ class CircuitState(Enum):
74
+ """Circuit breaker states."""
75
+
76
+ CLOSED = "closed" # Normal operation
77
+ OPEN = "open" # Failing, rejecting requests
78
+ HALF_OPEN = "half_open" # Testing if service recovered
79
+
80
+
81
+ class CircuitBreaker:
82
+ """Fail-fast circuit breaker for protecting against cascading failures.
83
+
84
+ State machine: CLOSED -> OPEN -> HALF_OPEN -> CLOSED (on success) or OPEN (on failure).
85
+
86
+ Example:
87
+ >>> cb = CircuitBreaker(failure_threshold=3, recovery_time=10.0)
88
+ >>> result = await cb.execute(api_call, arg1, kwarg=val)
89
+
90
+ Args:
91
+ failure_threshold: Failures before opening circuit.
92
+ recovery_time: Seconds to wait before transitioning to HALF_OPEN.
93
+ half_open_max_calls: Test calls allowed in HALF_OPEN state.
94
+ excluded_exceptions: Exception types that don't count as failures.
95
+ name: Identifier for logging/metrics.
96
+ """
97
+
98
+ def __init__(
99
+ self,
100
+ failure_threshold: int = 5,
101
+ recovery_time: float = 30.0,
102
+ half_open_max_calls: int = 1,
103
+ excluded_exceptions: set[type[Exception]] | None = None,
104
+ name: str = "default",
105
+ ):
106
+ """Initialize circuit breaker.
107
+
108
+ Raises:
109
+ ValueError: If thresholds or times are non-positive.
110
+ """
111
+ if failure_threshold <= 0:
112
+ raise ValueError("failure_threshold must be > 0")
113
+ if recovery_time <= 0:
114
+ raise ValueError("recovery_time must be > 0")
115
+ if half_open_max_calls <= 0:
116
+ raise ValueError("half_open_max_calls must be > 0")
117
+
118
+ self.failure_threshold = failure_threshold
119
+ self.recovery_time = recovery_time
120
+ self.half_open_max_calls = half_open_max_calls
121
+ self.excluded_exceptions = excluded_exceptions or set()
122
+ self.name = name
123
+
124
+ if Exception in self.excluded_exceptions:
125
+ logger.warning(
126
+ f"CircuitBreaker '{name}': excluding base Exception means circuit will never open"
127
+ )
128
+
129
+ self.failure_count = 0
130
+ self.state = CircuitState.CLOSED
131
+ self.last_failure_time = 0.0
132
+ self._half_open_calls = 0
133
+ self._lock = Lock()
134
+
135
+ self._metrics: _MetricsDict = {
136
+ "success_count": 0,
137
+ "failure_count": 0,
138
+ "rejected_count": 0,
139
+ "state_changes": [],
140
+ }
141
+
142
+ logger.debug(
143
+ f"Initialized CircuitBreaker '{self.name}' with failure_threshold={failure_threshold}, "
144
+ f"recovery_time={recovery_time}, half_open_max_calls={half_open_max_calls}"
145
+ )
146
+
147
+ @property
148
+ def metrics(self) -> dict[str, Any]:
149
+ """Get circuit breaker metrics snapshot (deep copy for thread-safety)."""
150
+ return {
151
+ "success_count": self._metrics["success_count"],
152
+ "failure_count": self._metrics["failure_count"],
153
+ "rejected_count": self._metrics["rejected_count"],
154
+ "state_changes": list(self._metrics["state_changes"]), # Deep copy list
155
+ }
156
+
157
+ def to_dict(self) -> dict[str, Any]:
158
+ """Serialize circuit breaker configuration."""
159
+ return {
160
+ "failure_threshold": self.failure_threshold,
161
+ "recovery_time": self.recovery_time,
162
+ "half_open_max_calls": self.half_open_max_calls,
163
+ "name": self.name,
164
+ }
165
+
166
+ async def _change_state(self, new_state: CircuitState) -> None:
167
+ """Transition to new state, reset counters, and log change."""
168
+ old_state = self.state
169
+ if new_state != old_state:
170
+ self.state = new_state
171
+ self._metrics["state_changes"].append(
172
+ {
173
+ "time": current_time(),
174
+ "from": old_state.value,
175
+ "to": new_state.value,
176
+ }
177
+ )
178
+
179
+ logger.info(
180
+ f"Circuit '{self.name}' state changed from {old_state.value} to {new_state.value}"
181
+ )
182
+
183
+ if new_state == CircuitState.HALF_OPEN:
184
+ self._half_open_calls = 0
185
+ elif new_state == CircuitState.CLOSED:
186
+ self.failure_count = 0
187
+
188
+ async def _check_state(self) -> tuple[bool, float]:
189
+ """Check if request can proceed based on current state.
190
+
191
+ Returns:
192
+ (can_proceed, retry_after_seconds) - retry_after is 0.0 if allowed.
193
+ """
194
+ async with self._lock:
195
+ now = current_time()
196
+
197
+ if self.state == CircuitState.OPEN:
198
+ # Check if recovery time has elapsed
199
+ if now - self.last_failure_time >= self.recovery_time:
200
+ await self._change_state(CircuitState.HALF_OPEN)
201
+ else:
202
+ recovery_remaining = self.recovery_time - (now - self.last_failure_time)
203
+ self._metrics["rejected_count"] += 1
204
+
205
+ logger.warning(
206
+ f"Circuit '{self.name}' is OPEN, rejecting request. "
207
+ f"Try again in {recovery_remaining:.2f}s"
208
+ )
209
+
210
+ return False, recovery_remaining
211
+
212
+ if self.state == CircuitState.HALF_OPEN:
213
+ if self._half_open_calls >= self.half_open_max_calls:
214
+ self._metrics["rejected_count"] += 1
215
+
216
+ logger.warning(
217
+ f"Circuit '{self.name}' is HALF_OPEN and at capacity. Try again later."
218
+ )
219
+
220
+ return False, self.recovery_time
221
+
222
+ self._half_open_calls += 1
223
+
224
+ return True, 0.0
225
+
226
+ async def execute(self, func: Callable[..., Awaitable[T]], *args: Any, **kwargs: Any) -> T:
227
+ """Execute async function with circuit breaker protection.
228
+
229
+ Args:
230
+ func: Async callable to execute.
231
+ *args: Positional arguments for func.
232
+ **kwargs: Keyword arguments for func.
233
+
234
+ Returns:
235
+ Result from func if successful.
236
+
237
+ Raises:
238
+ CircuitBreakerOpenError: If circuit is open or half-open at capacity.
239
+ Exception: Any exception from func (after recording failure).
240
+ """
241
+ can_proceed, retry_after = await self._check_state()
242
+ if not can_proceed:
243
+ raise CircuitBreakerOpenError(
244
+ f"Circuit breaker '{self.name}' is open. Retry after {retry_after:.2f} seconds",
245
+ retry_after=retry_after,
246
+ )
247
+
248
+ try:
249
+ logger.debug(
250
+ f"Executing {func.__name__} with circuit '{self.name}' state: {self.state.value}"
251
+ )
252
+ result = await func(*args, **kwargs)
253
+
254
+ async with self._lock:
255
+ self._metrics["success_count"] += 1
256
+ if self.state == CircuitState.HALF_OPEN:
257
+ await self._change_state(CircuitState.CLOSED)
258
+
259
+ return result
260
+
261
+ except Exception as e:
262
+ is_excluded = any(isinstance(e, exc_type) for exc_type in self.excluded_exceptions)
263
+
264
+ if not is_excluded:
265
+ async with self._lock:
266
+ self.failure_count += 1
267
+ self.last_failure_time = current_time()
268
+ self._metrics["failure_count"] += 1
269
+
270
+ logger.warning(
271
+ f"Circuit '{self.name}' failure: {e}. "
272
+ f"Count: {self.failure_count}/{self.failure_threshold}"
273
+ )
274
+
275
+ if (
276
+ self.state == CircuitState.CLOSED
277
+ and self.failure_count >= self.failure_threshold
278
+ ) or self.state == CircuitState.HALF_OPEN:
279
+ await self._change_state(CircuitState.OPEN)
280
+
281
+ logger.exception(f"Circuit breaker '{self.name}' caught exception")
282
+ raise
283
+
284
+
285
+ @dataclass(frozen=True, slots=True)
286
+ class RetryConfig:
287
+ """Immutable retry configuration with exponential backoff and jitter.
288
+
289
+ Example:
290
+ >>> config = RetryConfig(max_retries=5, initial_delay=0.5)
291
+ >>> await retry_with_backoff(api_call, **config.as_kwargs())
292
+ """
293
+
294
+ max_retries: int = 3
295
+ initial_delay: float = 1.0
296
+ max_delay: float = 60.0
297
+ exponential_base: float = 2.0
298
+ jitter: bool = True
299
+ retry_on: tuple[type[Exception], ...] = field(
300
+ default=(KronConnectionError, CircuitBreakerOpenError)
301
+ )
302
+
303
+ def __post_init__(self):
304
+ """Validate configuration parameters."""
305
+ if self.max_retries < 0:
306
+ raise ValueError("max_retries must be >= 0")
307
+ if self.initial_delay <= 0:
308
+ raise ValueError("initial_delay must be > 0")
309
+ if self.max_delay <= 0:
310
+ raise ValueError("max_delay must be > 0")
311
+ if self.max_delay < self.initial_delay:
312
+ raise ValueError("max_delay must be >= initial_delay")
313
+ if self.exponential_base <= 0:
314
+ raise ValueError("exponential_base must be > 0")
315
+
316
+ def calculate_delay(self, attempt: int) -> float:
317
+ """Calculate delay with exponential backoff + optional jitter.
318
+
319
+ Args:
320
+ attempt: Current retry attempt number (0-indexed)
321
+
322
+ Returns:
323
+ Delay in seconds before next retry
324
+ """
325
+ delay = min(self.initial_delay * (self.exponential_base**attempt), self.max_delay)
326
+ if self.jitter:
327
+ delay = delay * (0.5 + random.random() * 0.5)
328
+ return delay
329
+
330
+ def to_dict(self) -> dict[str, Any]:
331
+ """Serialize to dict (for persistence/logging)."""
332
+ return {
333
+ "max_retries": self.max_retries,
334
+ "initial_delay": self.initial_delay,
335
+ "max_delay": self.max_delay,
336
+ "exponential_base": self.exponential_base,
337
+ "jitter": self.jitter,
338
+ "retry_on": self.retry_on,
339
+ }
340
+
341
+ def as_kwargs(self) -> dict[str, Any]:
342
+ """Convert to kwargs dict for passing to retry_with_backoff()."""
343
+ return self.to_dict()
344
+
345
+
346
+ async def retry_with_backoff(
347
+ func: Callable[..., Awaitable[T]],
348
+ *args,
349
+ max_retries: int = 3,
350
+ initial_delay: float = 1.0,
351
+ max_delay: float = 60.0,
352
+ exponential_base: float = 2.0,
353
+ jitter: bool = True,
354
+ retry_on: tuple[type[Exception], ...] = (
355
+ KronConnectionError,
356
+ CircuitBreakerOpenError,
357
+ ),
358
+ **kwargs,
359
+ ) -> T:
360
+ """Retry async function with exponential backoff and optional jitter.
361
+
362
+ Only retries on specified exception types (transient errors by default).
363
+ Does NOT retry programming errors, file system errors, or timeouts unless
364
+ explicitly added to retry_on.
365
+
366
+ Args:
367
+ func: Async callable to execute.
368
+ *args: Positional arguments for func.
369
+ max_retries: Maximum retry attempts (0 = no retries).
370
+ initial_delay: Base delay in seconds before first retry.
371
+ max_delay: Maximum delay cap in seconds.
372
+ exponential_base: Multiplier for delay growth (delay * base^attempt).
373
+ jitter: If True, randomize delay by 50-100% to prevent thundering herd.
374
+ retry_on: Exception types to retry on.
375
+ **kwargs: Keyword arguments for func.
376
+
377
+ Returns:
378
+ Result from func on success.
379
+
380
+ Raises:
381
+ Exception: Last caught exception after all retries exhausted.
382
+
383
+ Example:
384
+ >>> result = await retry_with_backoff(
385
+ ... api_call, endpoint, max_retries=5, initial_delay=0.5
386
+ ... )
387
+ """
388
+ last_exception = None
389
+
390
+ for attempt in range(max_retries + 1):
391
+ try:
392
+ return await func(*args, **kwargs)
393
+ except retry_on as e:
394
+ last_exception = e
395
+
396
+ if attempt >= max_retries:
397
+ logger.error(f"All {max_retries} retry attempts exhausted for {func.__name__}: {e}")
398
+ raise
399
+
400
+ delay = min(initial_delay * (exponential_base**attempt), max_delay)
401
+
402
+ if jitter:
403
+ delay = delay * (0.5 + random.random() * 0.5)
404
+
405
+ logger.debug(
406
+ f"Retry attempt {attempt + 1}/{max_retries} for {func.__name__} "
407
+ f"after {delay:.2f}s: {e}"
408
+ )
409
+
410
+ await sleep(delay)
411
+
412
+ if last_exception: # pragma: no cover
413
+ raise last_exception # pragma: no cover
414
+ raise RuntimeError("Unexpected retry loop exit") # pragma: no cover
@@ -0,0 +1,41 @@
1
+ # Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Session: conversation orchestration with messages, branches, and services.
5
+
6
+ Core types:
7
+ Session: Central orchestrator owning branches, messages, services.
8
+ Branch: Message progression with access control (capabilities, resources).
9
+ Message: Inter-entity communication container.
10
+ Exchange: Async message router between entity mailboxes.
11
+
12
+ Validators (raise on failure):
13
+ resource_must_exist_in_session
14
+ resource_must_be_accessible_by_branch
15
+ capabilities_must_be_subset_of_branch
16
+ resolve_branch_exists_in_session
17
+ """
18
+
19
+ from .exchange import Exchange
20
+ from .message import Message
21
+ from .session import (
22
+ Branch,
23
+ Session,
24
+ SessionConfig,
25
+ capabilities_must_be_subset_of_branch,
26
+ resolve_branch_exists_in_session,
27
+ resource_must_be_accessible_by_branch,
28
+ resource_must_exist_in_session,
29
+ )
30
+
31
+ __all__ = (
32
+ "Branch",
33
+ "Exchange",
34
+ "Message",
35
+ "Session",
36
+ "SessionConfig",
37
+ "capabilities_must_be_subset_of_branch",
38
+ "resolve_branch_exists_in_session",
39
+ "resource_must_be_accessible_by_branch",
40
+ "resource_must_exist_in_session",
41
+ )