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.
- kronos/__init__.py +0 -0
- kronos/core/__init__.py +145 -0
- kronos/core/broadcaster.py +116 -0
- kronos/core/element.py +225 -0
- kronos/core/event.py +316 -0
- kronos/core/eventbus.py +116 -0
- kronos/core/flow.py +356 -0
- kronos/core/graph.py +442 -0
- kronos/core/node.py +982 -0
- kronos/core/pile.py +575 -0
- kronos/core/processor.py +494 -0
- kronos/core/progression.py +296 -0
- kronos/enforcement/__init__.py +57 -0
- kronos/enforcement/common/__init__.py +34 -0
- kronos/enforcement/common/boolean.py +85 -0
- kronos/enforcement/common/choice.py +97 -0
- kronos/enforcement/common/mapping.py +118 -0
- kronos/enforcement/common/model.py +102 -0
- kronos/enforcement/common/number.py +98 -0
- kronos/enforcement/common/string.py +140 -0
- kronos/enforcement/context.py +129 -0
- kronos/enforcement/policy.py +80 -0
- kronos/enforcement/registry.py +153 -0
- kronos/enforcement/rule.py +312 -0
- kronos/enforcement/service.py +370 -0
- kronos/enforcement/validator.py +198 -0
- kronos/errors.py +146 -0
- kronos/operations/__init__.py +32 -0
- kronos/operations/builder.py +228 -0
- kronos/operations/flow.py +398 -0
- kronos/operations/node.py +101 -0
- kronos/operations/registry.py +92 -0
- kronos/protocols.py +414 -0
- kronos/py.typed +0 -0
- kronos/services/__init__.py +81 -0
- kronos/services/backend.py +286 -0
- kronos/services/endpoint.py +608 -0
- kronos/services/hook.py +471 -0
- kronos/services/imodel.py +465 -0
- kronos/services/registry.py +115 -0
- kronos/services/utilities/__init__.py +36 -0
- kronos/services/utilities/header_factory.py +87 -0
- kronos/services/utilities/rate_limited_executor.py +271 -0
- kronos/services/utilities/rate_limiter.py +180 -0
- kronos/services/utilities/resilience.py +414 -0
- kronos/session/__init__.py +41 -0
- kronos/session/exchange.py +258 -0
- kronos/session/message.py +60 -0
- kronos/session/session.py +411 -0
- kronos/specs/__init__.py +25 -0
- kronos/specs/adapters/__init__.py +0 -0
- kronos/specs/adapters/_utils.py +45 -0
- kronos/specs/adapters/dataclass_field.py +246 -0
- kronos/specs/adapters/factory.py +56 -0
- kronos/specs/adapters/pydantic_adapter.py +309 -0
- kronos/specs/adapters/sql_ddl.py +946 -0
- kronos/specs/catalog/__init__.py +36 -0
- kronos/specs/catalog/_audit.py +39 -0
- kronos/specs/catalog/_common.py +43 -0
- kronos/specs/catalog/_content.py +59 -0
- kronos/specs/catalog/_enforcement.py +70 -0
- kronos/specs/factory.py +120 -0
- kronos/specs/operable.py +314 -0
- kronos/specs/phrase.py +405 -0
- kronos/specs/protocol.py +140 -0
- kronos/specs/spec.py +506 -0
- kronos/types/__init__.py +60 -0
- kronos/types/_sentinel.py +311 -0
- kronos/types/base.py +369 -0
- kronos/types/db_types.py +260 -0
- kronos/types/identity.py +66 -0
- kronos/utils/__init__.py +40 -0
- kronos/utils/_hash.py +234 -0
- kronos/utils/_json_dump.py +392 -0
- kronos/utils/_lazy_init.py +63 -0
- kronos/utils/_to_list.py +165 -0
- kronos/utils/_to_num.py +85 -0
- kronos/utils/_utils.py +375 -0
- kronos/utils/concurrency/__init__.py +205 -0
- kronos/utils/concurrency/_async_call.py +333 -0
- kronos/utils/concurrency/_cancel.py +122 -0
- kronos/utils/concurrency/_errors.py +96 -0
- kronos/utils/concurrency/_patterns.py +363 -0
- kronos/utils/concurrency/_primitives.py +328 -0
- kronos/utils/concurrency/_priority_queue.py +135 -0
- kronos/utils/concurrency/_resource_tracker.py +110 -0
- kronos/utils/concurrency/_run_async.py +67 -0
- kronos/utils/concurrency/_task.py +95 -0
- kronos/utils/concurrency/_utils.py +79 -0
- kronos/utils/fuzzy/__init__.py +14 -0
- kronos/utils/fuzzy/_extract_json.py +90 -0
- kronos/utils/fuzzy/_fuzzy_json.py +288 -0
- kronos/utils/fuzzy/_fuzzy_match.py +149 -0
- kronos/utils/fuzzy/_string_similarity.py +187 -0
- kronos/utils/fuzzy/_to_dict.py +396 -0
- kronos/utils/sql/__init__.py +13 -0
- kronos/utils/sql/_sql_validation.py +142 -0
- krons-0.1.0.dist-info/METADATA +70 -0
- krons-0.1.0.dist-info/RECORD +101 -0
- krons-0.1.0.dist-info/WHEEL +4 -0
- krons-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Exchange: async message router between entity mailboxes.
|
|
5
|
+
|
|
6
|
+
Manages Flow per entity with outbox/inbox progressions.
|
|
7
|
+
Supports broadcast, direct messaging, and continuous sync loops.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
from uuid import UUID
|
|
14
|
+
|
|
15
|
+
from pydantic import PrivateAttr
|
|
16
|
+
|
|
17
|
+
from kronos.core import Element, Flow, Pile, Progression
|
|
18
|
+
from kronos.errors import ExistsError
|
|
19
|
+
from kronos.utils import concurrency
|
|
20
|
+
|
|
21
|
+
from .message import Message
|
|
22
|
+
|
|
23
|
+
__all__ = ("OUTBOX", "Exchange")
|
|
24
|
+
|
|
25
|
+
OUTBOX = "outbox"
|
|
26
|
+
"""Standard outbox progression name."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _inbox_name(sender: UUID) -> str:
|
|
30
|
+
"""Inbox progression name for a sender: inbox_{uuid}."""
|
|
31
|
+
return f"inbox_{sender}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Exchange(Element):
|
|
35
|
+
"""Async message router between entity mailboxes.
|
|
36
|
+
|
|
37
|
+
Each registered entity gets a Flow with:
|
|
38
|
+
- "outbox" progression: queued outbound messages
|
|
39
|
+
- "inbox_{sender}" progressions: received messages by sender
|
|
40
|
+
|
|
41
|
+
Lifecycle:
|
|
42
|
+
1. register(owner_id) - create entity mailbox
|
|
43
|
+
2. send(sender, recipient, content) - queue message
|
|
44
|
+
3. collect(owner_id) or sync() - route queued messages
|
|
45
|
+
4. receive(owner_id) / pop_message() - read delivered messages
|
|
46
|
+
|
|
47
|
+
Thread Safety:
|
|
48
|
+
Uses Flow locks for concurrent access. collect() releases
|
|
49
|
+
lock before parallel delivery to avoid deadlocks.
|
|
50
|
+
|
|
51
|
+
Attributes:
|
|
52
|
+
flows: Pile of entity Flow mailboxes.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
flows: Pile[Flow[Message, Progression]] = None # type: ignore
|
|
56
|
+
_owner_index: dict[UUID, UUID] = PrivateAttr(default_factory=dict)
|
|
57
|
+
_stop: bool = PrivateAttr(default=False)
|
|
58
|
+
|
|
59
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
60
|
+
"""Initialize with empty flows pile."""
|
|
61
|
+
super().__init__(**kwargs)
|
|
62
|
+
if self.flows is None:
|
|
63
|
+
self.flows = Pile()
|
|
64
|
+
|
|
65
|
+
def register(self, owner_id: UUID) -> Flow[Message, Progression]:
|
|
66
|
+
"""Create mailbox Flow for entity. Raises ValueError if exists."""
|
|
67
|
+
if owner_id in self._owner_index:
|
|
68
|
+
raise ValueError(f"Owner {owner_id} already registered")
|
|
69
|
+
|
|
70
|
+
flow: Flow[Message, Progression] = Flow(
|
|
71
|
+
name=str(owner_id),
|
|
72
|
+
item_type={Message},
|
|
73
|
+
strict_type=True,
|
|
74
|
+
)
|
|
75
|
+
flow.add_progression(Progression(name=OUTBOX))
|
|
76
|
+
|
|
77
|
+
self.flows.add(flow)
|
|
78
|
+
self._owner_index[owner_id] = flow.id
|
|
79
|
+
|
|
80
|
+
return flow
|
|
81
|
+
|
|
82
|
+
def unregister(self, owner_id: UUID) -> Flow[Message, Progression] | None:
|
|
83
|
+
"""Remove entity mailbox. Returns removed Flow or None."""
|
|
84
|
+
flow_id = self._owner_index.pop(owner_id, None)
|
|
85
|
+
if flow_id is None:
|
|
86
|
+
return None
|
|
87
|
+
return self.flows.pop(flow_id, None)
|
|
88
|
+
|
|
89
|
+
def get(self, owner_id: UUID) -> Flow[Message, Progression] | None:
|
|
90
|
+
"""Get entity's mailbox Flow or None."""
|
|
91
|
+
flow_id = self._owner_index.get(owner_id)
|
|
92
|
+
if flow_id is None:
|
|
93
|
+
return None
|
|
94
|
+
return self.flows.get(flow_id, None)
|
|
95
|
+
|
|
96
|
+
def has(self, owner_id: UUID) -> bool:
|
|
97
|
+
"""True if entity is registered."""
|
|
98
|
+
return owner_id in self._owner_index
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def owner_ids(self) -> list[UUID]:
|
|
102
|
+
"""All registered entity UUIDs."""
|
|
103
|
+
return list(self._owner_index.keys())
|
|
104
|
+
|
|
105
|
+
async def collect(self, owner_id: UUID) -> int:
|
|
106
|
+
"""Route outbox messages to recipients. Returns count of unique messages routed.
|
|
107
|
+
|
|
108
|
+
Two-phase: collect under lock, then parallel delivery (lock released).
|
|
109
|
+
Broadcasts copy message per recipient. Unregistered recipients dropped.
|
|
110
|
+
|
|
111
|
+
Raises:
|
|
112
|
+
ValueError: If owner not registered.
|
|
113
|
+
"""
|
|
114
|
+
deliveries: list[tuple[UUID, Message]] = []
|
|
115
|
+
|
|
116
|
+
async with self.flows:
|
|
117
|
+
flow = self.get(owner_id)
|
|
118
|
+
if flow is None:
|
|
119
|
+
raise ValueError(f"Owner {owner_id} not registered")
|
|
120
|
+
|
|
121
|
+
outbox = flow.get_progression(OUTBOX)
|
|
122
|
+
|
|
123
|
+
while len(outbox) > 0:
|
|
124
|
+
message_id = outbox.popleft()
|
|
125
|
+
message = flow.items.pop(message_id, None)
|
|
126
|
+
if message is None:
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
if message.is_broadcast:
|
|
130
|
+
for other_id in self._owner_index:
|
|
131
|
+
if other_id != owner_id:
|
|
132
|
+
try:
|
|
133
|
+
message_copy = message.model_copy(deep=True)
|
|
134
|
+
except Exception:
|
|
135
|
+
message_copy = message.model_copy()
|
|
136
|
+
deliveries.append((other_id, message_copy))
|
|
137
|
+
elif message.recipient is not None and message.recipient in self._owner_index:
|
|
138
|
+
deliveries.append((message.recipient, message))
|
|
139
|
+
if deliveries:
|
|
140
|
+
await concurrency.gather(
|
|
141
|
+
*[self._deliver_to(recipient_id, message) for recipient_id, message in deliveries],
|
|
142
|
+
return_exceptions=True,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
unique_messages = {message.id for _, message in deliveries}
|
|
146
|
+
return len(unique_messages)
|
|
147
|
+
|
|
148
|
+
async def _deliver_to(self, recipient_id: UUID, message: Message) -> None:
|
|
149
|
+
"""Deliver to recipient inbox. No-op if recipient unregistered."""
|
|
150
|
+
recipient_flow = self.get(recipient_id)
|
|
151
|
+
if recipient_flow is None:
|
|
152
|
+
return # Recipient unregistered, drop message
|
|
153
|
+
|
|
154
|
+
inbox_name = _inbox_name(message.sender)
|
|
155
|
+
try:
|
|
156
|
+
recipient_flow.add_progression(Progression(name=inbox_name))
|
|
157
|
+
except ExistsError:
|
|
158
|
+
pass
|
|
159
|
+
|
|
160
|
+
recipient_flow.add_item(message, progressions=inbox_name)
|
|
161
|
+
|
|
162
|
+
async def collect_all(self) -> int:
|
|
163
|
+
"""Route all outboxes. Returns total messages routed."""
|
|
164
|
+
total = 0
|
|
165
|
+
for owner_id in list(self._owner_index.keys()):
|
|
166
|
+
try:
|
|
167
|
+
total += await self.collect(owner_id)
|
|
168
|
+
except ValueError:
|
|
169
|
+
continue
|
|
170
|
+
return total
|
|
171
|
+
|
|
172
|
+
async def sync(self) -> int:
|
|
173
|
+
"""Alias for collect_all(). Returns messages routed."""
|
|
174
|
+
return await self.collect_all()
|
|
175
|
+
|
|
176
|
+
async def run(self, interval: float = 1.0) -> None:
|
|
177
|
+
"""Continuous sync loop. Call stop() to exit."""
|
|
178
|
+
self._stop = False
|
|
179
|
+
while not self._stop:
|
|
180
|
+
await self.sync()
|
|
181
|
+
await concurrency.sleep(interval)
|
|
182
|
+
|
|
183
|
+
def stop(self) -> None:
|
|
184
|
+
"""Signal run() loop to exit."""
|
|
185
|
+
self._stop = True
|
|
186
|
+
|
|
187
|
+
def send(
|
|
188
|
+
self,
|
|
189
|
+
sender: UUID,
|
|
190
|
+
recipient: UUID | None,
|
|
191
|
+
content: Any,
|
|
192
|
+
channel: str | None = None,
|
|
193
|
+
) -> Message:
|
|
194
|
+
"""Create and queue message. Raises ValueError if sender not registered."""
|
|
195
|
+
flow = self.get(sender)
|
|
196
|
+
if flow is None:
|
|
197
|
+
raise ValueError(f"Sender {sender} not registered")
|
|
198
|
+
|
|
199
|
+
message = Message(sender=sender, recipient=recipient, content=content, channel=channel)
|
|
200
|
+
flow.add_item(message, progressions=OUTBOX)
|
|
201
|
+
return message
|
|
202
|
+
|
|
203
|
+
def receive(self, owner_id: UUID, sender: UUID | None = None) -> list[Message]:
|
|
204
|
+
"""Peek at inbound messages (non-destructive). Filter by sender if provided."""
|
|
205
|
+
flow = self.get(owner_id)
|
|
206
|
+
if flow is None:
|
|
207
|
+
return []
|
|
208
|
+
|
|
209
|
+
result = []
|
|
210
|
+
# Iterate over progressions Pile (public API) instead of _progression_names
|
|
211
|
+
for progression in flow.progressions:
|
|
212
|
+
prog_name = progression.name
|
|
213
|
+
if prog_name and prog_name.startswith("inbox_"):
|
|
214
|
+
if sender is not None:
|
|
215
|
+
expected_name = _inbox_name(sender)
|
|
216
|
+
if prog_name != expected_name:
|
|
217
|
+
continue
|
|
218
|
+
for message_id in progression:
|
|
219
|
+
message = flow.items.get(message_id, None)
|
|
220
|
+
if message is not None:
|
|
221
|
+
result.append(message)
|
|
222
|
+
return result
|
|
223
|
+
|
|
224
|
+
def pop_message(self, owner_id: UUID, sender: UUID) -> Message | None:
|
|
225
|
+
"""Pop oldest message from sender's inbox (FIFO). Returns None if empty."""
|
|
226
|
+
flow = self.get(owner_id)
|
|
227
|
+
if flow is None:
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
inbox_name = _inbox_name(sender)
|
|
231
|
+
try:
|
|
232
|
+
inbox = flow.get_progression(inbox_name)
|
|
233
|
+
except KeyError:
|
|
234
|
+
return None
|
|
235
|
+
|
|
236
|
+
if len(inbox) == 0:
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
message_id = inbox.popleft()
|
|
240
|
+
return flow.items.pop(message_id, None)
|
|
241
|
+
|
|
242
|
+
def __len__(self) -> int:
|
|
243
|
+
"""Count of registered entities."""
|
|
244
|
+
return len(self._owner_index)
|
|
245
|
+
|
|
246
|
+
def __contains__(self, owner_id: UUID) -> bool:
|
|
247
|
+
"""Support 'uuid in exchange' syntax."""
|
|
248
|
+
return self.has(owner_id)
|
|
249
|
+
|
|
250
|
+
def __repr__(self) -> str:
|
|
251
|
+
pending = 0
|
|
252
|
+
for flow in self.flows:
|
|
253
|
+
try:
|
|
254
|
+
outbox = flow.get_progression(OUTBOX)
|
|
255
|
+
pending += len(outbox)
|
|
256
|
+
except KeyError:
|
|
257
|
+
pass # No outbox progression
|
|
258
|
+
return f"Exchange(entities={len(self)}, pending_out={pending})"
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Message: typed container for inter-entity communication."""
|
|
5
|
+
|
|
6
|
+
from typing import Any
|
|
7
|
+
from uuid import UUID
|
|
8
|
+
|
|
9
|
+
from pydantic import Field, field_validator
|
|
10
|
+
|
|
11
|
+
from kronos.core import Node
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Message(Node):
|
|
15
|
+
"""Inter-entity communication container.
|
|
16
|
+
|
|
17
|
+
Messages are content carriers with optional routing (sender/recipient).
|
|
18
|
+
recipient=None indicates broadcast to all registered entities.
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
content: Payload (any serializable type).
|
|
22
|
+
sender: Sender entity UUID.
|
|
23
|
+
recipient: Target UUID (None=broadcast).
|
|
24
|
+
channel: Topic/namespace for filtering.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
content: Any
|
|
28
|
+
sender: UUID | None = None
|
|
29
|
+
recipient: UUID | None = None
|
|
30
|
+
channel: str | None = Field(None, description="Optional namespace for message grouping")
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def is_broadcast(self) -> bool:
|
|
34
|
+
"""True if no specific recipient (broadcast to all)."""
|
|
35
|
+
return self.recipient is None
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def is_direct(self) -> bool:
|
|
39
|
+
"""True if has specific recipient (point-to-point)."""
|
|
40
|
+
return self.recipient is not None
|
|
41
|
+
|
|
42
|
+
@field_validator("sender", "recipient", mode="before")
|
|
43
|
+
@classmethod
|
|
44
|
+
def _validate_uuid(cls, v):
|
|
45
|
+
"""Coerce UUID|str|Element-like to UUID."""
|
|
46
|
+
if v is None:
|
|
47
|
+
return None
|
|
48
|
+
if isinstance(v, UUID):
|
|
49
|
+
return v
|
|
50
|
+
if isinstance(v, str):
|
|
51
|
+
return UUID(v)
|
|
52
|
+
if hasattr(v, "id") and isinstance(v.id, UUID):
|
|
53
|
+
return v.id
|
|
54
|
+
raise ValueError(f"Expected UUID, str, or Element-like, got {type(v)}")
|
|
55
|
+
|
|
56
|
+
def __repr__(self) -> str:
|
|
57
|
+
sender_str = str(self.sender)[:8] if self.sender else "unknown"
|
|
58
|
+
target = str(self.recipient)[:8] if self.recipient else "broadcast"
|
|
59
|
+
channel_str = f", channel={self.channel}" if self.channel else ""
|
|
60
|
+
return f"Message({sender_str} -> {target}{channel_str})"
|