krons 0.1.1__py3-none-any.whl → 0.2.1__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 (142) hide show
  1. krons/__init__.py +49 -0
  2. krons/agent/__init__.py +144 -0
  3. krons/agent/mcps/__init__.py +14 -0
  4. krons/agent/mcps/loader.py +287 -0
  5. krons/agent/mcps/wrapper.py +799 -0
  6. krons/agent/message/__init__.py +20 -0
  7. krons/agent/message/action.py +69 -0
  8. krons/agent/message/assistant.py +52 -0
  9. krons/agent/message/common.py +49 -0
  10. krons/agent/message/instruction.py +130 -0
  11. krons/agent/message/prepare_msg.py +187 -0
  12. krons/agent/message/role.py +53 -0
  13. krons/agent/message/system.py +53 -0
  14. krons/agent/operations/__init__.py +82 -0
  15. krons/agent/operations/act.py +100 -0
  16. krons/agent/operations/generate.py +145 -0
  17. krons/agent/operations/llm_reparse.py +89 -0
  18. krons/agent/operations/operate.py +247 -0
  19. krons/agent/operations/parse.py +243 -0
  20. krons/agent/operations/react.py +286 -0
  21. krons/agent/operations/specs.py +235 -0
  22. krons/agent/operations/structure.py +151 -0
  23. krons/agent/operations/utils.py +79 -0
  24. krons/agent/providers/__init__.py +17 -0
  25. krons/agent/providers/anthropic_messages.py +146 -0
  26. krons/agent/providers/claude_code.py +276 -0
  27. krons/agent/providers/gemini.py +268 -0
  28. krons/agent/providers/match.py +75 -0
  29. krons/agent/providers/oai_chat.py +174 -0
  30. krons/agent/third_party/__init__.py +2 -0
  31. krons/agent/third_party/anthropic_models.py +154 -0
  32. krons/agent/third_party/claude_code.py +682 -0
  33. krons/agent/third_party/gemini_models.py +508 -0
  34. krons/agent/third_party/openai_models.py +295 -0
  35. krons/agent/tool.py +291 -0
  36. krons/core/__init__.py +56 -74
  37. krons/core/base/__init__.py +121 -0
  38. krons/core/{broadcaster.py → base/broadcaster.py} +7 -3
  39. krons/core/{element.py → base/element.py} +13 -5
  40. krons/core/{event.py → base/event.py} +39 -6
  41. krons/core/{eventbus.py → base/eventbus.py} +3 -1
  42. krons/core/{flow.py → base/flow.py} +11 -4
  43. krons/core/{graph.py → base/graph.py} +24 -8
  44. krons/core/{node.py → base/node.py} +44 -19
  45. krons/core/{pile.py → base/pile.py} +22 -8
  46. krons/core/{processor.py → base/processor.py} +21 -7
  47. krons/core/{progression.py → base/progression.py} +3 -1
  48. krons/{specs → core/specs}/__init__.py +0 -5
  49. krons/{specs → core/specs}/adapters/dataclass_field.py +16 -8
  50. krons/{specs → core/specs}/adapters/pydantic_adapter.py +11 -5
  51. krons/{specs → core/specs}/adapters/sql_ddl.py +14 -8
  52. krons/{specs → core/specs}/catalog/__init__.py +2 -2
  53. krons/{specs → core/specs}/catalog/_audit.py +2 -2
  54. krons/{specs → core/specs}/catalog/_common.py +2 -2
  55. krons/{specs → core/specs}/catalog/_content.py +4 -4
  56. krons/{specs → core/specs}/catalog/_enforcement.py +3 -3
  57. krons/{specs → core/specs}/factory.py +5 -5
  58. krons/{specs → core/specs}/operable.py +8 -2
  59. krons/{specs → core/specs}/protocol.py +4 -2
  60. krons/{specs → core/specs}/spec.py +23 -11
  61. krons/{types → core/types}/base.py +4 -2
  62. krons/{types → core/types}/db_types.py +2 -2
  63. krons/errors.py +13 -13
  64. krons/protocols.py +9 -4
  65. krons/resource/__init__.py +89 -0
  66. krons/{services → resource}/backend.py +48 -22
  67. krons/{services → resource}/endpoint.py +28 -14
  68. krons/{services → resource}/hook.py +20 -7
  69. krons/{services → resource}/imodel.py +46 -28
  70. krons/{services → resource}/registry.py +26 -24
  71. krons/{services → resource}/utilities/rate_limited_executor.py +7 -3
  72. krons/{services → resource}/utilities/rate_limiter.py +3 -1
  73. krons/{services → resource}/utilities/resilience.py +15 -5
  74. krons/resource/utilities/token_calculator.py +185 -0
  75. krons/session/__init__.py +12 -17
  76. krons/session/constraints.py +70 -0
  77. krons/session/exchange.py +11 -3
  78. krons/session/message.py +3 -1
  79. krons/session/registry.py +35 -0
  80. krons/session/session.py +165 -174
  81. krons/utils/__init__.py +45 -0
  82. krons/utils/_function_arg_parser.py +99 -0
  83. krons/utils/_pythonic_function_call.py +249 -0
  84. krons/utils/_to_list.py +9 -3
  85. krons/utils/_utils.py +6 -2
  86. krons/utils/concurrency/_async_call.py +4 -2
  87. krons/utils/concurrency/_errors.py +3 -1
  88. krons/utils/concurrency/_patterns.py +3 -1
  89. krons/utils/concurrency/_resource_tracker.py +6 -2
  90. krons/utils/display.py +257 -0
  91. krons/utils/fuzzy/__init__.py +6 -1
  92. krons/utils/fuzzy/_fuzzy_match.py +14 -8
  93. krons/utils/fuzzy/_string_similarity.py +3 -1
  94. krons/utils/fuzzy/_to_dict.py +3 -1
  95. krons/utils/schemas/__init__.py +26 -0
  96. krons/utils/schemas/_breakdown_pydantic_annotation.py +131 -0
  97. krons/utils/schemas/_formatter.py +72 -0
  98. krons/utils/schemas/_minimal_yaml.py +151 -0
  99. krons/utils/schemas/_typescript.py +153 -0
  100. krons/utils/validators/__init__.py +3 -0
  101. krons/utils/validators/_validate_image_url.py +56 -0
  102. krons/work/__init__.py +115 -0
  103. krons/work/engine.py +333 -0
  104. krons/work/form.py +242 -0
  105. krons/{operations → work/operations}/__init__.py +7 -4
  106. krons/{operations → work/operations}/builder.py +1 -1
  107. krons/{enforcement → work/operations}/context.py +36 -5
  108. krons/{operations → work/operations}/flow.py +13 -5
  109. krons/{operations → work/operations}/node.py +45 -43
  110. krons/work/operations/registry.py +103 -0
  111. krons/work/report.py +268 -0
  112. krons/work/rules/__init__.py +47 -0
  113. krons/{enforcement → work/rules}/common/boolean.py +3 -1
  114. krons/{enforcement → work/rules}/common/choice.py +9 -3
  115. krons/{enforcement → work/rules}/common/number.py +3 -1
  116. krons/{enforcement → work/rules}/common/string.py +9 -3
  117. krons/{enforcement → work/rules}/rule.py +1 -1
  118. krons/{enforcement → work/rules}/validator.py +20 -5
  119. krons/work/worker.py +266 -0
  120. {krons-0.1.1.dist-info → krons-0.2.1.dist-info}/METADATA +15 -1
  121. krons-0.2.1.dist-info/RECORD +151 -0
  122. krons/enforcement/__init__.py +0 -57
  123. krons/enforcement/policy.py +0 -80
  124. krons/enforcement/service.py +0 -370
  125. krons/operations/registry.py +0 -92
  126. krons/services/__init__.py +0 -81
  127. krons/specs/phrase.py +0 -405
  128. krons-0.1.1.dist-info/RECORD +0 -101
  129. /krons/{specs → core/specs}/adapters/__init__.py +0 -0
  130. /krons/{specs → core/specs}/adapters/_utils.py +0 -0
  131. /krons/{specs → core/specs}/adapters/factory.py +0 -0
  132. /krons/{types → core/types}/__init__.py +0 -0
  133. /krons/{types → core/types}/_sentinel.py +0 -0
  134. /krons/{types → core/types}/identity.py +0 -0
  135. /krons/{services → resource}/utilities/__init__.py +0 -0
  136. /krons/{services → resource}/utilities/header_factory.py +0 -0
  137. /krons/{enforcement → work/rules}/common/__init__.py +0 -0
  138. /krons/{enforcement → work/rules}/common/mapping.py +0 -0
  139. /krons/{enforcement → work/rules}/common/model.py +0 -0
  140. /krons/{enforcement → work/rules}/registry.py +0 -0
  141. {krons-0.1.1.dist-info → krons-0.2.1.dist-info}/WHEEL +0 -0
  142. {krons-0.1.1.dist-info → krons-0.2.1.dist-info}/licenses/LICENSE +0 -0
krons/session/session.py CHANGED
@@ -10,50 +10,27 @@ Branch is a named message progression with capability/resource access control.
10
10
  from __future__ import annotations
11
11
 
12
12
  import contextlib
13
- from collections.abc import Iterable
14
- from typing import TYPE_CHECKING, Any, Literal
13
+ from collections.abc import AsyncGenerator, Iterable
14
+ from typing import Any, Literal
15
15
  from uuid import UUID
16
16
 
17
- from pydantic import Field
17
+ from pydantic import Field, model_validator
18
18
 
19
- from krons.core import Element, Flow, Progression
20
- from krons.errors import AccessError, NotFoundError
21
- from krons.operations.node import Operation
22
- from krons.operations.registry import OperationRegistry
23
- from krons.services import ServiceRegistry
24
- from krons.types import HashableModel, Unset, UnsetType, not_sentinel
19
+ from krons.core import Element, Flow, Pile, Progression
20
+ from krons.core.types import HashableModel, Unset, UnsetType, not_sentinel
21
+ from krons.errors import NotFoundError
22
+ from krons.resource import Calling, ResourceRegistry, iModel
23
+ from krons.work.operations import Operation, OperationRegistry, RequestContext
25
24
 
26
25
  from .message import Message
27
26
 
28
- if TYPE_CHECKING:
29
- from krons.services.backend import Calling
30
-
31
27
  __all__ = (
32
28
  "Branch",
33
29
  "Session",
34
30
  "SessionConfig",
35
- "capabilities_must_be_subset_of_branch",
36
- "resource_must_be_accessible_by_branch",
37
- "resource_must_exist_in_session",
38
31
  )
39
32
 
40
33
 
41
- class SessionConfig(HashableModel):
42
- """Session initialization configuration.
43
-
44
- Attributes:
45
- default_branch_name: Name for auto-created default branch.
46
- default_capabilities: Capabilities granted to default branch.
47
- default_resources: Resources accessible by default branch.
48
- auto_create_default_branch: Create "main" branch on init.
49
- """
50
-
51
- default_branch_name: str | None = None
52
- default_capabilities: set[str] = Field(default_factory=set)
53
- default_resources: set[str] = Field(default_factory=set)
54
- auto_create_default_branch: bool = True
55
-
56
-
57
34
  class Branch(Progression):
58
35
  """Message progression with capability and resource access control.
59
36
 
@@ -67,95 +44,85 @@ class Branch(Progression):
67
44
  """
68
45
 
69
46
  session_id: UUID = Field(..., frozen=True)
70
- capabilities: set[str] = Field(default_factory=set)
71
- resources: set[str] = Field(default_factory=set)
47
+ capabilities: set[str] = Field(default_factory=set, frozen=True)
48
+ resources: set[str] = Field(default_factory=set, frozen=True)
72
49
 
73
50
  def __repr__(self) -> str:
74
51
  name_str = f" name='{self.name}'" if self.name else ""
75
52
  return f"Branch(messages={len(self)}, session={str(self.session_id)[:8]}{name_str})"
76
53
 
77
54
 
78
- class Session(Element):
79
- """Central orchestrator: messages, branches, services, operations.
80
-
81
- Lifecycle:
82
- 1. Create session (auto-creates default branch unless disabled)
83
- 2. Register services and operations
84
- 3. Create branches for different contexts/users
85
- 4. Add messages, conduct operations, make service requests
55
+ class SessionConfig(HashableModel):
56
+ default_branch_name: str | None = None
57
+ shared_capabilities: set[str] = Field(default_factory=set)
58
+ shared_resources: set[str] = Field(default_factory=set)
59
+ default_gen_model: str | None = None
60
+ default_parse_model: str | None = None
61
+ auto_create_default_branch: bool = True
86
62
 
87
- Attributes:
88
- user: Optional user identifier.
89
- communications: Flow containing messages and branches.
90
- services: Service registry for backend access.
91
- operations: Operation factory registry.
92
- config: Session configuration.
93
- default_branch_id: Branch used when none specified.
94
-
95
- Example:
96
- session = Session(user="agent-1")
97
- session.services.register("openai", openai_service)
98
- session.operations.register("chat", chat_factory)
99
- result = await session.conduct("chat", params={"prompt": "hello"})
100
- """
101
63
 
64
+ class Session(Element):
102
65
  user: str | None = None
103
- communications: Flow[Message, Branch] = None # type: ignore
104
- services: ServiceRegistry = None # type: ignore
105
- operations: OperationRegistry = None # type: ignore
106
- config: SessionConfig = None # type: ignore
66
+ communications: Flow[Message, Branch] = Field(
67
+ default_factory=lambda: Flow(item_type=Message)
68
+ )
69
+ resources: ResourceRegistry = Field(default_factory=ResourceRegistry)
70
+ operations: OperationRegistry = Field(default_factory=OperationRegistry)
71
+ config: SessionConfig = Field(default_factory=SessionConfig)
107
72
  default_branch_id: UUID | None = None
108
73
 
109
- def __init__(
110
- self,
111
- user: str | None = None,
112
- communications: Flow[Message, Branch] | None = None,
113
- services: ServiceRegistry | None = None,
114
- operations: OperationRegistry | None = None,
115
- config: SessionConfig | dict | None = None,
116
- default_branch_id: UUID | None = None,
117
- **data,
118
- ):
119
- """Initialize session with optional pre-configured components.
74
+ @model_validator(mode="after")
75
+ def _validate_default_branch(self) -> Session:
76
+ """Auto-create default branch and register built-in operations."""
77
+ if self.config.auto_create_default_branch and self.default_branch is None:
78
+ default_branch_name = self.config.default_branch_name or "main"
79
+ self.create_branch(
80
+ name=default_branch_name,
81
+ capabilities=self.config.shared_capabilities,
82
+ resources=self.config.shared_resources,
83
+ )
84
+ self.set_default_branch(default_branch_name)
85
+
86
+ # Register built-in operations (lazy import avoids circular)
87
+ from krons.agent.operations import (
88
+ generate,
89
+ operate,
90
+ react,
91
+ react_stream,
92
+ structure,
93
+ )
120
94
 
121
- Args:
122
- user: User identifier.
123
- communications: Pre-existing Flow (creates new if None).
124
- services: Pre-existing ServiceRegistry (creates new if None).
125
- operations: Pre-existing OperationRegistry (creates new if None).
126
- config: SessionConfig or dict (uses defaults if None).
127
- default_branch_id: Pre-set default branch.
128
- **data: Additional Element fields.
129
- """
130
- super().__init__(**data)
131
- self.user = user
132
- self.communications = communications or Flow(item_type=Message)
133
- self.services = services or ServiceRegistry()
134
- self.operations = operations or OperationRegistry()
135
- self.default_branch_id = default_branch_id
136
-
137
- if config is None:
138
- self.config = SessionConfig()
139
- elif isinstance(config, dict):
140
- self.config = SessionConfig(**config)
141
- else:
142
- self.config = config
95
+ for name, handler in (
96
+ ("generate", generate),
97
+ ("structure", structure),
98
+ ("operate", operate),
99
+ ("react", react),
100
+ ("react_stream", react_stream),
101
+ ):
102
+ if not self.operations.has(name):
103
+ self.operations.register(name, handler)
143
104
 
144
- if self.config.auto_create_default_branch and self.default_branch_id is None:
145
- branch = self.create_branch(
146
- name=self.config.default_branch_name or "main",
147
- capabilities=self.config.default_capabilities,
148
- resources=self.config.default_resources,
149
- )
150
- self.default_branch_id = branch.id
105
+ return self
106
+
107
+ @property
108
+ def default_gen_model(self) -> iModel | None:
109
+ if self.config.default_gen_model is None:
110
+ return None
111
+ return self.resources.get(self.config.default_gen_model)
112
+
113
+ @property
114
+ def default_parse_model(self) -> iModel | None:
115
+ if self.config.default_parse_model is None:
116
+ return None
117
+ return self.resources.get(self.config.default_parse_model)
151
118
 
152
119
  @property
153
- def messages(self):
120
+ def messages(self) -> Pile[Message]:
154
121
  """All messages in session (Pile[Message])."""
155
122
  return self.communications.items
156
123
 
157
124
  @property
158
- def branches(self):
125
+ def branches(self) -> Pile[Branch]:
159
126
  """All branches in session (Pile[Branch])."""
160
127
  return self.communications.progressions
161
128
 
@@ -187,10 +154,14 @@ class Session(Element):
187
154
  Returns:
188
155
  Created Branch added to session.
189
156
  """
157
+ if name:
158
+ from .constraints import branch_name_must_be_unique
159
+
160
+ branch_name_must_be_unique(self, name)
161
+
190
162
  order: list[UUID] = []
191
163
  if messages:
192
- for msg in messages:
193
- order.append(msg.id if isinstance(msg, Message) else msg)
164
+ order.extend([self._coerce_id(msg) for msg in messages])
194
165
 
195
166
  branch = Branch(
196
167
  session_id=self.id,
@@ -265,9 +236,13 @@ class Session(Element):
265
236
  name=name or f"{source.name}_fork",
266
237
  messages=source.order,
267
238
  capabilities=(
268
- {*source.capabilities} if capabilities is True else (capabilities or set())
239
+ {*source.capabilities}
240
+ if capabilities is True
241
+ else (capabilities or set())
242
+ ),
243
+ resources=(
244
+ {*source.resources} if resources is True else (resources or set())
269
245
  ),
270
- resources=({*source.resources} if resources is True else (resources or set())),
271
246
  )
272
247
 
273
248
  forked.metadata["forked_from"] = {
@@ -288,38 +263,25 @@ class Session(Element):
288
263
 
289
264
  async def request(
290
265
  self,
291
- service_name: str,
292
- *,
266
+ name: str,
267
+ /,
293
268
  branch: Branch | UUID | str | None = None,
294
269
  poll_timeout: float | None = None,
295
270
  poll_interval: float | None = None,
296
- **kwargs,
271
+ **options,
297
272
  ) -> Calling:
298
- """Direct service invocation with optional access control.
299
-
300
- Args:
301
- service_name: Registered service name.
302
- branch: If provided, checks service in branch.resources.
303
- poll_timeout: Max wait seconds.
304
- poll_interval: Poll interval seconds.
305
- **kwargs: Service-specific arguments.
306
-
307
- Returns:
308
- Calling with execution results.
309
-
310
- Raises:
311
- AccessError: If branch lacks access to service.
312
- NotFoundError: If service not registered.
313
- """
314
273
  if branch is not None:
315
274
  resolved_branch = self.get_branch(branch)
316
- resource_must_be_accessible_by_branch(resolved_branch, service_name)
317
275
 
318
- service = self.services.get(service_name)
319
- return await service.invoke(
276
+ from .constraints import resource_must_be_accessible
277
+
278
+ resource_must_be_accessible(resolved_branch, name)
279
+
280
+ resource = self.resources.get(name)
281
+ return await resource.invoke(
320
282
  poll_timeout=poll_timeout,
321
283
  poll_interval=poll_interval,
322
- **kwargs,
284
+ **options,
323
285
  )
324
286
 
325
287
  async def conduct(
@@ -327,6 +289,7 @@ class Session(Element):
327
289
  operation_type: str,
328
290
  branch: Branch | UUID | str | None = None,
329
291
  params: Any | None = None,
292
+ verbose: bool = False,
330
293
  ) -> Operation:
331
294
  """Execute operation via registry.
332
295
 
@@ -334,6 +297,7 @@ class Session(Element):
334
297
  operation_type: Registry key.
335
298
  branch: Target branch (default if None).
336
299
  params: Operation parameters.
300
+ verbose: Print real-time status updates.
337
301
 
338
302
  Returns:
339
303
  Invoked Operation (result in op.execution.response).
@@ -343,16 +307,85 @@ class Session(Element):
343
307
  KeyError: Operation not registered.
344
308
  """
345
309
  resolved = self._resolve_branch(branch)
310
+
311
+ if verbose:
312
+ from krons.utils.display import Timer, status
313
+
314
+ branch_name = resolved.name or str(resolved.id)[:8]
315
+ status(
316
+ f"conduct({operation_type}) on branch={branch_name}",
317
+ style="info",
318
+ )
319
+
346
320
  op = Operation(
347
321
  operation_type=operation_type,
348
322
  parameters=params,
349
- timeout=None,
350
- streaming=False,
351
323
  )
324
+ op._verbose = verbose
352
325
  op.bind(self, resolved)
353
- await op.invoke()
326
+
327
+ if verbose:
328
+ with Timer(f"{operation_type} completed"):
329
+ await op.invoke()
330
+
331
+ resp = op.execution.response
332
+ if op.execution.error:
333
+ status(f"ERROR: {op.execution.error}", style="error")
334
+ elif isinstance(resp, str):
335
+ status(f"response: {len(resp)} chars", style="success")
336
+ else:
337
+ status(f"response: {type(resp).__name__}", style="success")
338
+ else:
339
+ await op.invoke()
340
+
354
341
  return op
355
342
 
343
+ async def stream_conduct(
344
+ self,
345
+ operation_type: str,
346
+ branch: Branch | UUID | str | None = None,
347
+ params: Any | None = None,
348
+ verbose: bool = False,
349
+ ) -> AsyncGenerator[Any, None]:
350
+ """Stream operation results via async generator.
351
+
352
+ For streaming handlers like react_stream that yield intermediate
353
+ results per round. Bypasses the Operation wrapper since streaming
354
+ handlers produce multiple values, not a single response.
355
+
356
+ Args:
357
+ operation_type: Registry key (e.g. "react_stream").
358
+ branch: Target branch (default if None).
359
+ params: Operation parameters.
360
+ verbose: Enable real-time streaming output.
361
+
362
+ Yields:
363
+ Handler results (e.g., ReActAnalysis per round).
364
+ """
365
+ resolved = self._resolve_branch(branch)
366
+ handler = self.operations.get(operation_type)
367
+
368
+ ctx = RequestContext(
369
+ name=operation_type,
370
+ session_id=self.id,
371
+ branch=resolved.name or str(resolved.id),
372
+ _bound_session=self,
373
+ _bound_branch=resolved,
374
+ _verbose=verbose,
375
+ )
376
+
377
+ if verbose:
378
+ from krons.utils.display import status
379
+
380
+ branch_name = resolved.name or str(resolved.id)[:8]
381
+ status(
382
+ f"stream_conduct({operation_type}) on branch={branch_name}",
383
+ style="info",
384
+ )
385
+
386
+ async for result in handler(params, ctx):
387
+ yield result
388
+
356
389
  def _resolve_branch(self, branch: Branch | UUID | str | None) -> Branch:
357
390
  """Resolve to Branch, falling back to default. Raises if neither available."""
358
391
  if branch is not None:
@@ -365,47 +398,5 @@ class Session(Element):
365
398
  return (
366
399
  f"Session(messages={len(self.messages)}, "
367
400
  f"branches={len(self.branches)}, "
368
- f"services={len(self.services)})"
369
- )
370
-
371
-
372
- def resource_must_exist_in_session(session: Session, name: str) -> None:
373
- """Validate service exists. Raise NotFoundError with available names if not."""
374
- if not session.services.has(name):
375
- raise NotFoundError(
376
- f"Service '{name}' not found in session services",
377
- details={"available": session.services.list_names()},
378
- )
379
-
380
-
381
- def resource_must_be_accessible_by_branch(branch: Branch, name: str) -> None:
382
- """Validate branch has resource access. Raise AccessError if not."""
383
- if name not in branch.resources:
384
- raise AccessError(
385
- f"Branch '{branch.name}' has no access to resource '{name}'",
386
- details={
387
- "branch": branch.name,
388
- "resource": name,
389
- "available": list(branch.resources),
390
- },
401
+ f"services={len(self.resources)})"
391
402
  )
392
-
393
-
394
- def capabilities_must_be_subset_of_branch(branch: Branch, capabilities: set[str]) -> None:
395
- """Validate branch has all capabilities. Raise AccessError listing missing."""
396
- if not capabilities.issubset(branch.capabilities):
397
- missing = capabilities - branch.capabilities
398
- raise AccessError(
399
- f"Branch '{branch.name}' missing capabilities: {missing}",
400
- details={
401
- "requested": sorted(capabilities),
402
- "available": sorted(branch.capabilities),
403
- },
404
- )
405
-
406
-
407
- def resolve_branch_exists_in_session(session: Session, branch: Branch | str) -> Branch:
408
- """Get branch from session. Raise NotFoundError if not found."""
409
- if (b_ := session.get_branch(branch, None)) is None:
410
- raise NotFoundError(f"Branch '{branch}' does not exist in session")
411
- return b_
krons/utils/__init__.py CHANGED
@@ -38,3 +38,48 @@ from .sql._sql_validation import (
38
38
  sanitize_order_by,
39
39
  validate_identifier,
40
40
  )
41
+
42
+ __all__ = (
43
+ # _hash
44
+ "GENESIS_HASH",
45
+ "MAX_HASH_INPUT_BYTES",
46
+ "HashAlgorithm",
47
+ "compute_chain_hash",
48
+ "compute_hash",
49
+ "hash_obj",
50
+ # _json_dump
51
+ "json_dump",
52
+ "json_dumpb",
53
+ "json_lines_iter",
54
+ # _to_list, _to_num
55
+ "to_list",
56
+ "to_num",
57
+ # _utils
58
+ "async_synchronized",
59
+ "coerce_created_at",
60
+ "create_path",
61
+ "extract_types",
62
+ "get_bins",
63
+ "import_module",
64
+ "is_import_installed",
65
+ "load_type_from_string",
66
+ "now_utc",
67
+ "register_type_prefix",
68
+ "synchronized",
69
+ "to_uuid",
70
+ # concurrency
71
+ "alcall",
72
+ "is_coro_func",
73
+ # fuzzy
74
+ "SimilarityAlgo",
75
+ "extract_json",
76
+ "fuzzy_json",
77
+ "fuzzy_match_keys",
78
+ "string_similarity",
79
+ "to_dict",
80
+ # sql
81
+ "MAX_IDENTIFIER_LENGTH",
82
+ "SAFE_IDENTIFIER_PATTERN",
83
+ "sanitize_order_by",
84
+ "validate_identifier",
85
+ )
@@ -0,0 +1,99 @@
1
+ import types
2
+ from typing import Any, Union, get_args, get_origin
3
+
4
+ from pydantic import BaseModel
5
+
6
+
7
+ def map_positional_args(
8
+ arguments: dict[str, Any], param_names: list[str]
9
+ ) -> dict[str, Any]:
10
+ """Map positional arguments (_pos_0, _pos_1, ...) to actual parameter names."""
11
+ mapped = {}
12
+ pos_count = 0
13
+
14
+ for key, value in arguments.items():
15
+ if key.startswith("_pos_"):
16
+ if pos_count >= len(param_names):
17
+ raise ValueError(
18
+ f"Too many positional arguments (expected {len(param_names)})"
19
+ )
20
+ mapped[param_names[pos_count]] = value
21
+ pos_count += 1
22
+ else:
23
+ # Keep keyword arguments as-is
24
+ mapped[key] = value
25
+
26
+ return mapped
27
+
28
+
29
+ def _get_nested_fields_from_annotation(annotation) -> set[str]:
30
+ """Extract all field names from an annotation that may be a Pydantic model or Union."""
31
+ origin = get_origin(annotation)
32
+
33
+ # Handle Union types (typing.Union or types.UnionType)
34
+ if origin is Union or isinstance(annotation, types.UnionType):
35
+ union_members = get_args(annotation)
36
+ all_fields = set()
37
+ for member in union_members:
38
+ if member is type(None):
39
+ continue
40
+ # Recursively check nested unions or models
41
+ nested = _get_nested_fields_from_annotation(member)
42
+ all_fields.update(nested)
43
+ return all_fields
44
+
45
+ # Handle direct Pydantic model
46
+ if isinstance(annotation, type) and issubclass(annotation, BaseModel):
47
+ return set(annotation.model_fields.keys())
48
+
49
+ # Handle Pydantic model class (not instance)
50
+ if hasattr(annotation, "model_fields"):
51
+ return set(annotation.model_fields.keys())
52
+
53
+ return set()
54
+
55
+
56
+ def nest_arguments_by_schema(arguments: dict[str, Any], schema_cls) -> dict[str, Any]:
57
+ """Restructure flat arguments into nested format based on schema structure."""
58
+ if not schema_cls or not hasattr(schema_cls, "model_fields"):
59
+ return arguments
60
+
61
+ # Get top-level field names
62
+ top_level_fields = set(schema_cls.model_fields.keys())
63
+
64
+ # Find fields that are nested objects (Pydantic models or unions)
65
+ nested_field_mappings = {}
66
+ for field_name, field_info in schema_cls.model_fields.items():
67
+ annotation = field_info.annotation
68
+ nested_fields = _get_nested_fields_from_annotation(annotation)
69
+ if nested_fields:
70
+ nested_field_mappings[field_name] = nested_fields
71
+
72
+ # If no nested fields detected, return as-is
73
+ if not nested_field_mappings:
74
+ return arguments
75
+
76
+ # Separate top-level args from nested args
77
+ result: dict[str, Any] = {}
78
+ nested_args: dict[str, dict[str, Any]] = {}
79
+
80
+ for key, value in arguments.items():
81
+ if key in top_level_fields:
82
+ # This is a top-level field
83
+ result[key] = value
84
+ else:
85
+ # Check if this belongs to a nested field
86
+ for nested_field, nested_keys in nested_field_mappings.items():
87
+ if key in nested_keys:
88
+ if nested_field not in nested_args:
89
+ nested_args[nested_field] = {}
90
+ nested_args[nested_field][key] = value
91
+ break
92
+ else:
93
+ # Unknown field - keep at top level (will fail validation later)
94
+ result[key] = value
95
+
96
+ # Add nested structures to result
97
+ result.update(nested_args)
98
+
99
+ return result