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,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})"