digitalkin 0.2.23__py3-none-any.whl → 0.3.1.dev2__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 (78) hide show
  1. digitalkin/__version__.py +1 -1
  2. digitalkin/core/__init__.py +1 -0
  3. digitalkin/core/common/__init__.py +9 -0
  4. digitalkin/core/common/factories.py +156 -0
  5. digitalkin/core/job_manager/__init__.py +1 -0
  6. digitalkin/{modules → core}/job_manager/base_job_manager.py +137 -31
  7. digitalkin/core/job_manager/single_job_manager.py +354 -0
  8. digitalkin/{modules → core}/job_manager/taskiq_broker.py +116 -22
  9. digitalkin/core/job_manager/taskiq_job_manager.py +541 -0
  10. digitalkin/core/task_manager/__init__.py +1 -0
  11. digitalkin/core/task_manager/base_task_manager.py +539 -0
  12. digitalkin/core/task_manager/local_task_manager.py +108 -0
  13. digitalkin/core/task_manager/remote_task_manager.py +87 -0
  14. digitalkin/core/task_manager/surrealdb_repository.py +266 -0
  15. digitalkin/core/task_manager/task_executor.py +249 -0
  16. digitalkin/core/task_manager/task_session.py +406 -0
  17. digitalkin/grpc_servers/__init__.py +1 -19
  18. digitalkin/grpc_servers/_base_server.py +3 -3
  19. digitalkin/grpc_servers/module_server.py +27 -43
  20. digitalkin/grpc_servers/module_servicer.py +51 -36
  21. digitalkin/grpc_servers/registry_server.py +2 -2
  22. digitalkin/grpc_servers/registry_servicer.py +4 -4
  23. digitalkin/grpc_servers/utils/__init__.py +1 -0
  24. digitalkin/grpc_servers/utils/exceptions.py +0 -8
  25. digitalkin/grpc_servers/utils/grpc_client_wrapper.py +4 -4
  26. digitalkin/grpc_servers/utils/grpc_error_handler.py +53 -0
  27. digitalkin/logger.py +73 -24
  28. digitalkin/mixins/__init__.py +19 -0
  29. digitalkin/mixins/base_mixin.py +10 -0
  30. digitalkin/mixins/callback_mixin.py +24 -0
  31. digitalkin/mixins/chat_history_mixin.py +110 -0
  32. digitalkin/mixins/cost_mixin.py +76 -0
  33. digitalkin/mixins/file_history_mixin.py +93 -0
  34. digitalkin/mixins/filesystem_mixin.py +46 -0
  35. digitalkin/mixins/logger_mixin.py +51 -0
  36. digitalkin/mixins/storage_mixin.py +79 -0
  37. digitalkin/models/core/__init__.py +1 -0
  38. digitalkin/{modules/job_manager → models/core}/job_manager_models.py +3 -3
  39. digitalkin/models/core/task_monitor.py +70 -0
  40. digitalkin/models/grpc_servers/__init__.py +1 -0
  41. digitalkin/{grpc_servers/utils → models/grpc_servers}/models.py +5 -5
  42. digitalkin/models/module/__init__.py +2 -0
  43. digitalkin/models/module/module.py +9 -1
  44. digitalkin/models/module/module_context.py +122 -6
  45. digitalkin/models/module/module_types.py +307 -19
  46. digitalkin/models/services/__init__.py +9 -0
  47. digitalkin/models/services/cost.py +1 -0
  48. digitalkin/models/services/storage.py +39 -5
  49. digitalkin/modules/_base_module.py +123 -118
  50. digitalkin/modules/tool_module.py +10 -2
  51. digitalkin/modules/trigger_handler.py +7 -6
  52. digitalkin/services/cost/__init__.py +9 -2
  53. digitalkin/services/cost/grpc_cost.py +9 -42
  54. digitalkin/services/filesystem/default_filesystem.py +0 -2
  55. digitalkin/services/filesystem/grpc_filesystem.py +10 -39
  56. digitalkin/services/setup/default_setup.py +5 -6
  57. digitalkin/services/setup/grpc_setup.py +52 -15
  58. digitalkin/services/storage/grpc_storage.py +4 -4
  59. digitalkin/services/user_profile/__init__.py +1 -0
  60. digitalkin/services/user_profile/default_user_profile.py +55 -0
  61. digitalkin/services/user_profile/grpc_user_profile.py +69 -0
  62. digitalkin/services/user_profile/user_profile_strategy.py +40 -0
  63. digitalkin/utils/__init__.py +28 -0
  64. digitalkin/utils/arg_parser.py +1 -1
  65. digitalkin/utils/development_mode_action.py +2 -2
  66. digitalkin/utils/dynamic_schema.py +483 -0
  67. digitalkin/utils/package_discover.py +1 -2
  68. {digitalkin-0.2.23.dist-info → digitalkin-0.3.1.dev2.dist-info}/METADATA +11 -30
  69. digitalkin-0.3.1.dev2.dist-info/RECORD +119 -0
  70. modules/dynamic_setup_module.py +362 -0
  71. digitalkin/grpc_servers/utils/factory.py +0 -180
  72. digitalkin/modules/job_manager/single_job_manager.py +0 -294
  73. digitalkin/modules/job_manager/taskiq_job_manager.py +0 -290
  74. digitalkin-0.2.23.dist-info/RECORD +0 -89
  75. /digitalkin/{grpc_servers/utils → models/grpc_servers}/types.py +0 -0
  76. {digitalkin-0.2.23.dist-info → digitalkin-0.3.1.dev2.dist-info}/WHEEL +0 -0
  77. {digitalkin-0.2.23.dist-info → digitalkin-0.3.1.dev2.dist-info}/licenses/LICENSE +0 -0
  78. {digitalkin-0.2.23.dist-info → digitalkin-0.3.1.dev2.dist-info}/top_level.txt +0 -0
@@ -1,17 +1,31 @@
1
1
  """Types for module models."""
2
2
 
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import types
7
+ import typing
3
8
  from datetime import datetime, timezone
4
- from typing import Any, ClassVar, Generic, TypeVar, cast
9
+ from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypeVar, cast, get_args, get_origin
5
10
 
6
11
  from pydantic import BaseModel, ConfigDict, Field, create_model
7
12
 
8
13
  from digitalkin.logger import logger
14
+ from digitalkin.utils.dynamic_schema import (
15
+ DynamicField,
16
+ get_fetchers,
17
+ has_dynamic,
18
+ resolve_safe,
19
+ )
20
+
21
+ if TYPE_CHECKING:
22
+ from pydantic.fields import FieldInfo
9
23
 
10
24
 
11
25
  class DataTrigger(BaseModel):
12
- """Defines the root input model exposing the protocol.
26
+ """Defines the root input/output model exposing the protocol.
13
27
 
14
- The mandatory protocol is important to define the module beahvior following the user or agent input.
28
+ The mandatory protocol is important to define the module beahvior following the user or agent input/output.
15
29
 
16
30
  Example:
17
31
  class MyInput(DataModel):
@@ -24,16 +38,20 @@ class DataTrigger(BaseModel):
24
38
  """
25
39
 
26
40
  protocol: ClassVar[str]
27
- created_at: str = datetime.now(tz=timezone.utc).isoformat()
41
+ created_at: str = Field(
42
+ default_factory=lambda: datetime.now(tz=timezone.utc).isoformat(),
43
+ title="Created At",
44
+ description="Timestamp when the payload was created.",
45
+ )
28
46
 
29
47
 
30
48
  DataTriggerT = TypeVar("DataTriggerT", bound=DataTrigger)
31
49
 
32
50
 
33
51
  class DataModel(BaseModel, Generic[DataTriggerT]):
34
- """Base definition of input model showing mandatory root fields.
52
+ """Base definition of input/output model showing mandatory root fields.
35
53
 
36
- The Model define the Module Input, usually referring to multiple input type defined by an union.
54
+ The Model define the Module Input/output, usually referring to multiple input/output type defined by an union.
37
55
 
38
56
  Example:
39
57
  class ModuleInput(DataModel):
@@ -57,27 +75,50 @@ SetupModelT = TypeVar("SetupModelT", bound="SetupModel")
57
75
  class SetupModel(BaseModel):
58
76
  """Base definition of setup model showing mandatory root fields.
59
77
 
60
- Optionally, the setup model can define a config option in json_schema_extra to be used to initialize the Kin.
78
+ Optionally, the setup model can define a config option in json_schema_extra
79
+ to be used to initialize the Kin. Supports dynamic schema providers for
80
+ runtime value generation.
61
81
 
62
- Example:
63
- class MySetup(SetupModel):
64
- name: str = Field()
65
- number: int = Field(..., json_schema_extra={"config": True})
82
+ Attributes:
83
+ model_fields: Inherited from Pydantic BaseModel, contains field definitions.
84
+
85
+ See Also:
86
+ - Documentation: docs/api/dynamic_schema.md
87
+ - Tests: tests/modules/test_setup_model.py
66
88
  """
67
89
 
68
90
  @classmethod
69
- def get_clean_model(cls, *, config_fields: bool, hidden_fields: bool) -> type[SetupModelT]: # type: ignore
70
- """Dynamically builds and returns a new BaseModel subclass.
91
+ async def get_clean_model(
92
+ cls,
93
+ *,
94
+ config_fields: bool,
95
+ hidden_fields: bool,
96
+ force: bool = False,
97
+ ) -> type[SetupModelT]:
98
+ """Dynamically builds and returns a new BaseModel subclass with filtered fields.
71
99
 
72
- containing only those fields where json_schema_extra["config"] == True.
100
+ This method filters fields based on their `json_schema_extra` metadata:
101
+ - Fields with `{"config": True}` are included only when `config_fields=True`
102
+ - Fields with `{"hidden": True}` are included only when `hidden_fields=True`
73
103
 
74
- Returns:
75
- Type[BaseModel]: A new BaseModel subclass with the filtered fields.
104
+ When `force=True`, fields with dynamic schema providers will have their
105
+ providers called to fetch fresh values for schema metadata like enums.
106
+ This includes recursively processing nested BaseModel fields.
76
107
 
77
- Raises:
78
- ValueError: If both config_fields and hidden_fields are set to True.
108
+ Args:
109
+ config_fields: If True, include fields marked with `{"config": True}`.
110
+ These are typically initial configuration fields.
111
+ hidden_fields: If True, include fields marked with `{"hidden": True}`.
112
+ These are typically runtime-only fields not shown in initial config.
113
+ force: If True, refresh dynamic schema fields by calling their providers.
114
+ Use this when you need up-to-date values from external sources like
115
+ databases or APIs. Default is False for performance.
116
+
117
+ Returns:
118
+ A new BaseModel subclass with filtered fields.
79
119
  """
80
120
  clean_fields: dict[str, Any] = {}
121
+
81
122
  for name, field_info in cls.model_fields.items():
82
123
  extra = getattr(field_info, "json_schema_extra", {}) or {}
83
124
  is_config = bool(extra.get("config", False))
@@ -93,7 +134,27 @@ class SetupModel(BaseModel):
93
134
  logger.debug("Skipping '%s' (hidden-only)", name)
94
135
  continue
95
136
 
96
- clean_fields[name] = (field_info.annotation, field_info)
137
+ # Refresh dynamic schema fields when force=True
138
+ current_field_info = field_info
139
+ current_annotation = field_info.annotation
140
+
141
+ if force:
142
+ # Check if this field has DynamicField metadata
143
+ if has_dynamic(field_info):
144
+ current_field_info = await cls._refresh_field_schema(name, field_info)
145
+
146
+ # Check if the annotation is a nested BaseModel that might have dynamic fields
147
+ nested_model = cls._get_base_model_type(current_annotation)
148
+ if nested_model is not None:
149
+ refreshed_nested = await cls._refresh_nested_model(nested_model)
150
+ if refreshed_nested is not nested_model:
151
+ # Update annotation to use refreshed nested model
152
+ current_annotation = refreshed_nested
153
+ # Create new field_info with updated annotation (deep copy for safety)
154
+ current_field_info = copy.deepcopy(current_field_info)
155
+ setattr(current_field_info, "annotation", current_annotation)
156
+
157
+ clean_fields[name] = (current_annotation, current_field_info)
97
158
 
98
159
  # Dynamically create a model e.g. "SetupModel"
99
160
  m = create_model(
@@ -103,3 +164,230 @@ class SetupModel(BaseModel):
103
164
  **clean_fields,
104
165
  )
105
166
  return cast("type[SetupModelT]", m)
167
+
168
+ @classmethod
169
+ def _get_base_model_type(cls, annotation: type | None) -> type[BaseModel] | None:
170
+ """Extract BaseModel type from an annotation.
171
+
172
+ Handles direct types, Optional, Union, list, dict, set, tuple, and other generics.
173
+
174
+ Args:
175
+ annotation: The type annotation to inspect.
176
+
177
+ Returns:
178
+ The BaseModel subclass if found, None otherwise.
179
+ """
180
+ if annotation is None:
181
+ return None
182
+
183
+ # Direct BaseModel subclass check
184
+ if isinstance(annotation, type) and issubclass(annotation, BaseModel):
185
+ return annotation
186
+
187
+ origin = get_origin(annotation)
188
+ if origin is None:
189
+ return None
190
+
191
+ args = get_args(annotation)
192
+ return cls._extract_base_model_from_args(origin, args)
193
+
194
+ @classmethod
195
+ def _extract_base_model_from_args(
196
+ cls,
197
+ origin: type,
198
+ args: tuple[type, ...],
199
+ ) -> type[BaseModel] | None:
200
+ """Extract BaseModel from generic type arguments.
201
+
202
+ Args:
203
+ origin: The generic origin type (list, dict, Union, etc.).
204
+ args: The type arguments.
205
+
206
+ Returns:
207
+ The BaseModel subclass if found, None otherwise.
208
+ """
209
+ # Union/Optional: check each arg (supports both typing.Union and types.UnionType)
210
+ # Python 3.10+ uses types.UnionType for X | Y syntax
211
+ if origin is typing.Union or origin is types.UnionType:
212
+ return cls._find_base_model_in_args(args)
213
+
214
+ # list, set, frozenset: check first arg
215
+ if origin in {list, set, frozenset} and args:
216
+ return cls._check_base_model(args[0])
217
+
218
+ # dict: check value type (second arg)
219
+ dict_value_index = 1
220
+ if origin is dict and len(args) > dict_value_index:
221
+ return cls._check_base_model(args[dict_value_index])
222
+
223
+ # tuple: check first non-ellipsis arg
224
+ if origin is tuple:
225
+ return cls._find_base_model_in_args(args, skip_ellipsis=True)
226
+
227
+ return None
228
+
229
+ @classmethod
230
+ def _check_base_model(cls, arg: type) -> type[BaseModel] | None:
231
+ """Check if arg is a BaseModel subclass.
232
+
233
+ Returns:
234
+ The BaseModel subclass if arg is one, None otherwise.
235
+ """
236
+ if isinstance(arg, type) and issubclass(arg, BaseModel):
237
+ return arg
238
+ return None
239
+
240
+ @classmethod
241
+ def _find_base_model_in_args(
242
+ cls,
243
+ args: tuple[type, ...],
244
+ *,
245
+ skip_ellipsis: bool = False,
246
+ ) -> type[BaseModel] | None:
247
+ """Find first BaseModel in args.
248
+
249
+ Returns:
250
+ The first BaseModel subclass found, None otherwise.
251
+ """
252
+ for arg in args:
253
+ if arg is type(None):
254
+ continue
255
+ if skip_ellipsis and arg is ...:
256
+ continue
257
+ result = cls._check_base_model(arg)
258
+ if result is not None:
259
+ return result
260
+ return None
261
+
262
+ @classmethod
263
+ async def _refresh_nested_model(cls, model_cls: type[BaseModel]) -> type[BaseModel]:
264
+ """Refresh dynamic fields in a nested BaseModel.
265
+
266
+ Creates a new model class with all DynamicField metadata resolved.
267
+
268
+ Args:
269
+ model_cls: The nested model class to refresh.
270
+
271
+ Returns:
272
+ A new model class with refreshed fields, or the original if no changes.
273
+ """
274
+ has_changes = False
275
+ clean_fields: dict[str, Any] = {}
276
+
277
+ for name, field_info in model_cls.model_fields.items():
278
+ current_field_info = field_info
279
+ current_annotation = field_info.annotation
280
+
281
+ # Check if field has DynamicField metadata
282
+ if has_dynamic(field_info):
283
+ current_field_info = await cls._refresh_field_schema(name, field_info)
284
+ has_changes = True
285
+
286
+ # Recursively check nested models
287
+ nested_model = cls._get_base_model_type(current_annotation)
288
+ if nested_model is not None:
289
+ refreshed_nested = await cls._refresh_nested_model(nested_model)
290
+ if refreshed_nested is not nested_model:
291
+ current_annotation = refreshed_nested
292
+ current_field_info = copy.deepcopy(current_field_info)
293
+ setattr(current_field_info, "annotation", current_annotation)
294
+ has_changes = True
295
+
296
+ clean_fields[name] = (current_annotation, current_field_info)
297
+
298
+ if not has_changes:
299
+ return model_cls
300
+
301
+ # Create new model with refreshed fields
302
+ logger.debug("Creating refreshed nested model for '%s'", model_cls.__name__)
303
+ return create_model(
304
+ model_cls.__name__,
305
+ __base__=BaseModel,
306
+ __config__=ConfigDict(arbitrary_types_allowed=True),
307
+ **clean_fields,
308
+ )
309
+
310
+ @classmethod
311
+ async def _refresh_field_schema(cls, field_name: str, field_info: FieldInfo) -> FieldInfo:
312
+ """Refresh a field's json_schema_extra with fresh values from dynamic providers.
313
+
314
+ This method calls all dynamic providers registered for a field (via Annotated
315
+ metadata) and creates a new FieldInfo with the resolved values. The original
316
+ field_info is not modified.
317
+
318
+ Uses `resolve_safe()` for structured error handling, allowing partial success
319
+ when some fetchers fail. Successfully resolved values are still applied.
320
+
321
+ Args:
322
+ field_name: The name of the field being refreshed (used for logging).
323
+ field_info: The original FieldInfo object containing the dynamic providers.
324
+
325
+ Returns:
326
+ A new FieldInfo object with the same attributes as the original, but with
327
+ `json_schema_extra` containing resolved values and Dynamic metadata removed.
328
+
329
+ Note:
330
+ If all fetchers fail, the original field_info is returned unchanged.
331
+ If some fetchers fail, successfully resolved values are still applied.
332
+ """
333
+ fetchers = get_fetchers(field_info)
334
+
335
+ if not fetchers:
336
+ return field_info
337
+
338
+ fetcher_keys = list(fetchers.keys())
339
+ logger.debug(
340
+ "Refreshing dynamic schema for field '%s' with fetchers: %s",
341
+ field_name,
342
+ fetcher_keys,
343
+ extra={"field_name": field_name, "fetcher_keys": fetcher_keys},
344
+ )
345
+
346
+ # Resolve all fetchers with structured error handling
347
+ result = await resolve_safe(fetchers)
348
+
349
+ # Log any errors that occurred with full details
350
+ if result.errors:
351
+ for key, error in result.errors.items():
352
+ logger.warning(
353
+ "Failed to resolve '%s' for field '%s': %s: %s",
354
+ key,
355
+ field_name,
356
+ type(error).__name__,
357
+ str(error) or "(no message)",
358
+ extra={
359
+ "field_name": field_name,
360
+ "fetcher_key": key,
361
+ "error_type": type(error).__name__,
362
+ "error_message": str(error),
363
+ "error_repr": repr(error),
364
+ },
365
+ )
366
+
367
+ # If no values were resolved, return original field_info
368
+ if not result.values:
369
+ logger.warning(
370
+ "All fetchers failed for field '%s', keeping original",
371
+ field_name,
372
+ )
373
+ return field_info
374
+
375
+ # Build new json_schema_extra with resolved values merged
376
+ extra = getattr(field_info, "json_schema_extra", {}) or {}
377
+ new_extra = {**extra, **result.values}
378
+
379
+ # Create a deep copy of the FieldInfo to avoid shared mutable state
380
+ new_field_info = copy.deepcopy(field_info)
381
+ setattr(new_field_info, "json_schema_extra", new_extra)
382
+
383
+ # Remove Dynamic from metadata (it's been resolved)
384
+ new_metadata = [m for m in new_field_info.metadata if not isinstance(m, DynamicField)]
385
+ setattr(new_field_info, "metadata", new_metadata)
386
+
387
+ logger.debug(
388
+ "Refreshed '%s' with dynamic values: %s",
389
+ field_name,
390
+ list(result.values.keys()),
391
+ )
392
+
393
+ return new_field_info
@@ -1 +1,10 @@
1
1
  """This module contains the models for the services."""
2
+
3
+ from digitalkin.models.services.storage import BaseMessage, BaseRole, ChatHistory, Role
4
+
5
+ __all__ = [
6
+ "BaseMessage",
7
+ "BaseRole",
8
+ "ChatHistory",
9
+ "Role",
10
+ ]
@@ -38,6 +38,7 @@ class CostConfig(BaseModel):
38
38
  class CostEvent(BaseModel):
39
39
  """Pydantic model that represents a cost event registered during service execution.
40
40
 
41
+ # DEPRECATED
41
42
  :param cost_name: Identifier for the cost configuration.
42
43
  :param cost_type: The type of cost.
43
44
  :param usage: The amount or units consumed.
@@ -1,10 +1,44 @@
1
1
  """Storage model."""
2
2
 
3
- from pydantic import BaseModel
3
+ from enum import Enum
4
+ from typing import Any
4
5
 
6
+ from pydantic import BaseModel, Field
5
7
 
6
- class StorageModel(BaseModel):
7
- """Storage model."""
8
8
 
9
- data: object
10
- type: str
9
+ class BaseRole(str, Enum):
10
+ """Officially supported Role Enum for chat messages."""
11
+
12
+ ASSISTANT = "assistant"
13
+ USER = "user"
14
+ SYSTEM = "system"
15
+
16
+
17
+ Role = BaseRole | str
18
+
19
+
20
+ class BaseMessage(BaseModel):
21
+ """Base Model representing a simple message in the chat history."""
22
+
23
+ role: Role = Field(..., description="Role of the message sender")
24
+ content: Any = Field(..., description="The content of the message | preferably a BaseModel.")
25
+
26
+
27
+ class ChatHistory(BaseModel):
28
+ """Storage chat history model for the OpenAI Archetype module."""
29
+
30
+ messages: list[BaseMessage] = Field(..., description="List of messages in the chat history")
31
+
32
+
33
+ class FileModel(BaseModel):
34
+ """File model."""
35
+
36
+ file_id: str = Field(..., description="ID of the file")
37
+ name: str = Field(..., description="Name of the file")
38
+ metadata: dict[str, Any] = Field(..., description="Metadata of the file")
39
+
40
+
41
+ class FileHistory(BaseModel):
42
+ """File history model."""
43
+
44
+ files: list[FileModel] = Field(..., description="List of files")