digitalkin 0.3.2.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.
- base_server/__init__.py +1 -0
- base_server/mock/__init__.py +5 -0
- base_server/mock/mock_pb2.py +39 -0
- base_server/mock/mock_pb2_grpc.py +102 -0
- base_server/server_async_insecure.py +125 -0
- base_server/server_async_secure.py +143 -0
- base_server/server_sync_insecure.py +103 -0
- base_server/server_sync_secure.py +122 -0
- digitalkin/__init__.py +8 -0
- digitalkin/__version__.py +8 -0
- digitalkin/core/__init__.py +1 -0
- digitalkin/core/common/__init__.py +9 -0
- digitalkin/core/common/factories.py +156 -0
- digitalkin/core/job_manager/__init__.py +1 -0
- digitalkin/core/job_manager/base_job_manager.py +288 -0
- digitalkin/core/job_manager/single_job_manager.py +354 -0
- digitalkin/core/job_manager/taskiq_broker.py +311 -0
- digitalkin/core/job_manager/taskiq_job_manager.py +541 -0
- digitalkin/core/task_manager/__init__.py +1 -0
- digitalkin/core/task_manager/base_task_manager.py +539 -0
- digitalkin/core/task_manager/local_task_manager.py +108 -0
- digitalkin/core/task_manager/remote_task_manager.py +87 -0
- digitalkin/core/task_manager/surrealdb_repository.py +266 -0
- digitalkin/core/task_manager/task_executor.py +249 -0
- digitalkin/core/task_manager/task_session.py +406 -0
- digitalkin/grpc_servers/__init__.py +1 -0
- digitalkin/grpc_servers/_base_server.py +486 -0
- digitalkin/grpc_servers/module_server.py +208 -0
- digitalkin/grpc_servers/module_servicer.py +516 -0
- digitalkin/grpc_servers/utils/__init__.py +1 -0
- digitalkin/grpc_servers/utils/exceptions.py +29 -0
- digitalkin/grpc_servers/utils/grpc_client_wrapper.py +88 -0
- digitalkin/grpc_servers/utils/grpc_error_handler.py +53 -0
- digitalkin/grpc_servers/utils/utility_schema_extender.py +97 -0
- digitalkin/logger.py +157 -0
- digitalkin/mixins/__init__.py +19 -0
- digitalkin/mixins/base_mixin.py +10 -0
- digitalkin/mixins/callback_mixin.py +24 -0
- digitalkin/mixins/chat_history_mixin.py +110 -0
- digitalkin/mixins/cost_mixin.py +76 -0
- digitalkin/mixins/file_history_mixin.py +93 -0
- digitalkin/mixins/filesystem_mixin.py +46 -0
- digitalkin/mixins/logger_mixin.py +51 -0
- digitalkin/mixins/storage_mixin.py +79 -0
- digitalkin/models/__init__.py +8 -0
- digitalkin/models/core/__init__.py +1 -0
- digitalkin/models/core/job_manager_models.py +36 -0
- digitalkin/models/core/task_monitor.py +70 -0
- digitalkin/models/grpc_servers/__init__.py +1 -0
- digitalkin/models/grpc_servers/models.py +275 -0
- digitalkin/models/grpc_servers/types.py +24 -0
- digitalkin/models/module/__init__.py +25 -0
- digitalkin/models/module/module.py +40 -0
- digitalkin/models/module/module_context.py +149 -0
- digitalkin/models/module/module_types.py +393 -0
- digitalkin/models/module/utility.py +146 -0
- digitalkin/models/services/__init__.py +10 -0
- digitalkin/models/services/cost.py +54 -0
- digitalkin/models/services/registry.py +42 -0
- digitalkin/models/services/storage.py +44 -0
- digitalkin/modules/__init__.py +11 -0
- digitalkin/modules/_base_module.py +517 -0
- digitalkin/modules/archetype_module.py +23 -0
- digitalkin/modules/tool_module.py +23 -0
- digitalkin/modules/trigger_handler.py +48 -0
- digitalkin/modules/triggers/__init__.py +12 -0
- digitalkin/modules/triggers/healthcheck_ping_trigger.py +45 -0
- digitalkin/modules/triggers/healthcheck_services_trigger.py +63 -0
- digitalkin/modules/triggers/healthcheck_status_trigger.py +52 -0
- digitalkin/py.typed +0 -0
- digitalkin/services/__init__.py +30 -0
- digitalkin/services/agent/__init__.py +6 -0
- digitalkin/services/agent/agent_strategy.py +19 -0
- digitalkin/services/agent/default_agent.py +13 -0
- digitalkin/services/base_strategy.py +22 -0
- digitalkin/services/communication/__init__.py +7 -0
- digitalkin/services/communication/communication_strategy.py +76 -0
- digitalkin/services/communication/default_communication.py +101 -0
- digitalkin/services/communication/grpc_communication.py +223 -0
- digitalkin/services/cost/__init__.py +14 -0
- digitalkin/services/cost/cost_strategy.py +100 -0
- digitalkin/services/cost/default_cost.py +114 -0
- digitalkin/services/cost/grpc_cost.py +138 -0
- digitalkin/services/filesystem/__init__.py +7 -0
- digitalkin/services/filesystem/default_filesystem.py +417 -0
- digitalkin/services/filesystem/filesystem_strategy.py +252 -0
- digitalkin/services/filesystem/grpc_filesystem.py +317 -0
- digitalkin/services/identity/__init__.py +6 -0
- digitalkin/services/identity/default_identity.py +15 -0
- digitalkin/services/identity/identity_strategy.py +14 -0
- digitalkin/services/registry/__init__.py +27 -0
- digitalkin/services/registry/default_registry.py +141 -0
- digitalkin/services/registry/exceptions.py +47 -0
- digitalkin/services/registry/grpc_registry.py +306 -0
- digitalkin/services/registry/registry_models.py +43 -0
- digitalkin/services/registry/registry_strategy.py +98 -0
- digitalkin/services/services_config.py +200 -0
- digitalkin/services/services_models.py +65 -0
- digitalkin/services/setup/__init__.py +1 -0
- digitalkin/services/setup/default_setup.py +219 -0
- digitalkin/services/setup/grpc_setup.py +343 -0
- digitalkin/services/setup/setup_strategy.py +145 -0
- digitalkin/services/snapshot/__init__.py +6 -0
- digitalkin/services/snapshot/default_snapshot.py +39 -0
- digitalkin/services/snapshot/snapshot_strategy.py +30 -0
- digitalkin/services/storage/__init__.py +7 -0
- digitalkin/services/storage/default_storage.py +228 -0
- digitalkin/services/storage/grpc_storage.py +214 -0
- digitalkin/services/storage/storage_strategy.py +273 -0
- digitalkin/services/user_profile/__init__.py +12 -0
- digitalkin/services/user_profile/default_user_profile.py +55 -0
- digitalkin/services/user_profile/grpc_user_profile.py +69 -0
- digitalkin/services/user_profile/user_profile_strategy.py +40 -0
- digitalkin/utils/__init__.py +29 -0
- digitalkin/utils/arg_parser.py +92 -0
- digitalkin/utils/development_mode_action.py +51 -0
- digitalkin/utils/dynamic_schema.py +483 -0
- digitalkin/utils/llm_ready_schema.py +75 -0
- digitalkin/utils/package_discover.py +357 -0
- digitalkin-0.3.2.dev2.dist-info/METADATA +602 -0
- digitalkin-0.3.2.dev2.dist-info/RECORD +131 -0
- digitalkin-0.3.2.dev2.dist-info/WHEEL +5 -0
- digitalkin-0.3.2.dev2.dist-info/licenses/LICENSE +430 -0
- digitalkin-0.3.2.dev2.dist-info/top_level.txt +4 -0
- modules/__init__.py +0 -0
- modules/cpu_intensive_module.py +280 -0
- modules/dynamic_setup_module.py +338 -0
- modules/minimal_llm_module.py +347 -0
- modules/text_transform_module.py +203 -0
- services/filesystem_module.py +200 -0
- services/storage_module.py +206 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Define the module context used in the triggers."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from datetime import tzinfo
|
|
5
|
+
from types import SimpleNamespace
|
|
6
|
+
from typing import Any
|
|
7
|
+
from zoneinfo import ZoneInfo
|
|
8
|
+
|
|
9
|
+
from digitalkin.services.agent.agent_strategy import AgentStrategy
|
|
10
|
+
from digitalkin.services.communication.communication_strategy import CommunicationStrategy
|
|
11
|
+
from digitalkin.services.cost.cost_strategy import CostStrategy
|
|
12
|
+
from digitalkin.services.filesystem.filesystem_strategy import FilesystemStrategy
|
|
13
|
+
from digitalkin.services.identity.identity_strategy import IdentityStrategy
|
|
14
|
+
from digitalkin.services.registry.registry_strategy import RegistryStrategy
|
|
15
|
+
from digitalkin.services.snapshot.snapshot_strategy import SnapshotStrategy
|
|
16
|
+
from digitalkin.services.storage.storage_strategy import StorageStrategy
|
|
17
|
+
from digitalkin.services.user_profile.user_profile_strategy import UserProfileStrategy
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Session(SimpleNamespace):
|
|
21
|
+
"""Session data container with mandatory setup_id and mission_id."""
|
|
22
|
+
|
|
23
|
+
job_id: str
|
|
24
|
+
mission_id: str
|
|
25
|
+
setup_id: str
|
|
26
|
+
setup_version_id: str
|
|
27
|
+
timezone: tzinfo
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
job_id: str,
|
|
32
|
+
mission_id: str,
|
|
33
|
+
setup_id: str,
|
|
34
|
+
setup_version_id: str,
|
|
35
|
+
timezone: tzinfo | None = None,
|
|
36
|
+
**kwargs: dict[str, Any],
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Init Module Session.
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
ValueError: If mandatory args are missing.
|
|
42
|
+
"""
|
|
43
|
+
if not setup_id:
|
|
44
|
+
msg = "setup_id is mandatory"
|
|
45
|
+
raise ValueError(msg)
|
|
46
|
+
if not setup_version_id:
|
|
47
|
+
msg = "setup_version_id is mandatory"
|
|
48
|
+
raise ValueError(msg)
|
|
49
|
+
if not mission_id:
|
|
50
|
+
msg = "mission_id is mandatory"
|
|
51
|
+
raise ValueError(msg)
|
|
52
|
+
if not job_id:
|
|
53
|
+
msg = "job_id is mandatory"
|
|
54
|
+
raise ValueError(msg)
|
|
55
|
+
|
|
56
|
+
self.job_id = job_id
|
|
57
|
+
self.mission_id = mission_id
|
|
58
|
+
self.setup_id = setup_id
|
|
59
|
+
self.setup_version_id = setup_version_id
|
|
60
|
+
self.timezone = timezone or ZoneInfo(os.environ.get("DIGITALKIN_TIMEZONE", "Europe/Paris"))
|
|
61
|
+
|
|
62
|
+
super().__init__(**kwargs)
|
|
63
|
+
|
|
64
|
+
def current_ids(self) -> dict[str, str]:
|
|
65
|
+
"""Return current session ids as a dictionary.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
A dictionary containing the current session ids.
|
|
69
|
+
"""
|
|
70
|
+
return {
|
|
71
|
+
"job_id": self.job_id,
|
|
72
|
+
"mission_id": self.mission_id,
|
|
73
|
+
"setup_id": self.setup_id,
|
|
74
|
+
"setup_version_id": self.setup_version_id,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ModuleContext:
|
|
79
|
+
"""ModuleContext provides a container for strategies and resources used by a module.
|
|
80
|
+
|
|
81
|
+
This context object is designed to be passed to module components, providing them with
|
|
82
|
+
access to shared strategies and resources. Additional attributes may be set dynamically.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
# services list
|
|
86
|
+
agent: AgentStrategy
|
|
87
|
+
communication: CommunicationStrategy
|
|
88
|
+
cost: CostStrategy
|
|
89
|
+
filesystem: FilesystemStrategy
|
|
90
|
+
identity: IdentityStrategy
|
|
91
|
+
registry: RegistryStrategy
|
|
92
|
+
snapshot: SnapshotStrategy
|
|
93
|
+
storage: StorageStrategy
|
|
94
|
+
user_profile: UserProfileStrategy
|
|
95
|
+
|
|
96
|
+
session: Session
|
|
97
|
+
callbacks: SimpleNamespace
|
|
98
|
+
metadata: SimpleNamespace
|
|
99
|
+
helpers: SimpleNamespace
|
|
100
|
+
state: SimpleNamespace = SimpleNamespace()
|
|
101
|
+
|
|
102
|
+
def __init__( # noqa: PLR0913, PLR0917
|
|
103
|
+
self,
|
|
104
|
+
agent: AgentStrategy,
|
|
105
|
+
communication: CommunicationStrategy,
|
|
106
|
+
cost: CostStrategy,
|
|
107
|
+
filesystem: FilesystemStrategy,
|
|
108
|
+
identity: IdentityStrategy,
|
|
109
|
+
registry: RegistryStrategy,
|
|
110
|
+
snapshot: SnapshotStrategy,
|
|
111
|
+
storage: StorageStrategy,
|
|
112
|
+
user_profile: UserProfileStrategy,
|
|
113
|
+
session: dict[str, Any],
|
|
114
|
+
metadata: dict[str, Any] = {},
|
|
115
|
+
helpers: dict[str, Any] = {},
|
|
116
|
+
callbacks: dict[str, Any] = {},
|
|
117
|
+
) -> None:
|
|
118
|
+
"""Register mandatory services, session, metadata and callbacks.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
agent: AgentStrategy.
|
|
122
|
+
communication: CommunicationStrategy.
|
|
123
|
+
cost: CostStrategy.
|
|
124
|
+
filesystem: FilesystemStrategy.
|
|
125
|
+
identity: IdentityStrategy.
|
|
126
|
+
registry: RegistryStrategy.
|
|
127
|
+
snapshot: SnapshotStrategy.
|
|
128
|
+
storage: StorageStrategy.
|
|
129
|
+
user_profile: UserProfileStrategy.
|
|
130
|
+
metadata: dict defining differents Module metadata.
|
|
131
|
+
helpers: dict different user defined helpers.
|
|
132
|
+
session: dict referring the session IDs or informations.
|
|
133
|
+
callbacks: Functions allowing user to agent interaction.
|
|
134
|
+
"""
|
|
135
|
+
# Core services
|
|
136
|
+
self.agent = agent
|
|
137
|
+
self.communication = communication
|
|
138
|
+
self.cost = cost
|
|
139
|
+
self.filesystem = filesystem
|
|
140
|
+
self.identity = identity
|
|
141
|
+
self.registry = registry
|
|
142
|
+
self.snapshot = snapshot
|
|
143
|
+
self.storage = storage
|
|
144
|
+
self.user_profile = user_profile
|
|
145
|
+
|
|
146
|
+
self.metadata = SimpleNamespace(**metadata)
|
|
147
|
+
self.session = Session(**session)
|
|
148
|
+
self.helpers = SimpleNamespace(**helpers)
|
|
149
|
+
self.callbacks = SimpleNamespace(**callbacks)
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""Types for module models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
import types
|
|
7
|
+
import typing
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypeVar, cast, get_args, get_origin
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, ConfigDict, Field, create_model
|
|
12
|
+
|
|
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
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class DataTrigger(BaseModel):
|
|
26
|
+
"""Defines the root input/output model exposing the protocol.
|
|
27
|
+
|
|
28
|
+
The mandatory protocol is important to define the module beahvior following the user or agent input/output.
|
|
29
|
+
|
|
30
|
+
Example:
|
|
31
|
+
class MyInput(DataModel):
|
|
32
|
+
root: DataTrigger
|
|
33
|
+
user_define_data: Any
|
|
34
|
+
|
|
35
|
+
# Usage
|
|
36
|
+
my_input = MyInput(root=DataTrigger(protocol="message"))
|
|
37
|
+
print(my_input.root.protocol) # Output: message
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
protocol: ClassVar[str]
|
|
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
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
DataTriggerT = TypeVar("DataTriggerT", bound=DataTrigger)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class DataModel(BaseModel, Generic[DataTriggerT]):
|
|
52
|
+
"""Base definition of input/output model showing mandatory root fields.
|
|
53
|
+
|
|
54
|
+
The Model define the Module Input/output, usually referring to multiple input/output type defined by an union.
|
|
55
|
+
|
|
56
|
+
Example:
|
|
57
|
+
class ModuleInput(DataModel):
|
|
58
|
+
root: FileInput | MessageInput
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
root: DataTriggerT
|
|
62
|
+
annotations: dict[str, str] = Field(
|
|
63
|
+
default={},
|
|
64
|
+
title="Annotations",
|
|
65
|
+
description="Additional metadata or annotations related to the output. ex {'role': 'user'}",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
InputModelT = TypeVar("InputModelT", bound=DataModel)
|
|
70
|
+
OutputModelT = TypeVar("OutputModelT", bound=DataModel)
|
|
71
|
+
SecretModelT = TypeVar("SecretModelT", bound=BaseModel)
|
|
72
|
+
SetupModelT = TypeVar("SetupModelT", bound="SetupModel")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class SetupModel(BaseModel):
|
|
76
|
+
"""Base definition of setup model showing mandatory root fields.
|
|
77
|
+
|
|
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.
|
|
81
|
+
|
|
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
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
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.
|
|
99
|
+
|
|
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`
|
|
103
|
+
|
|
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.
|
|
107
|
+
|
|
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.
|
|
119
|
+
"""
|
|
120
|
+
clean_fields: dict[str, Any] = {}
|
|
121
|
+
|
|
122
|
+
for name, field_info in cls.model_fields.items():
|
|
123
|
+
extra = getattr(field_info, "json_schema_extra", {}) or {}
|
|
124
|
+
is_config = bool(extra.get("config", False))
|
|
125
|
+
is_hidden = bool(extra.get("hidden", False))
|
|
126
|
+
|
|
127
|
+
# Skip config unless explicitly included
|
|
128
|
+
if is_config and not config_fields:
|
|
129
|
+
logger.debug("Skipping '%s' (config-only)", name)
|
|
130
|
+
continue
|
|
131
|
+
|
|
132
|
+
# Skip hidden unless explicitly included
|
|
133
|
+
if is_hidden and not hidden_fields:
|
|
134
|
+
logger.debug("Skipping '%s' (hidden-only)", name)
|
|
135
|
+
continue
|
|
136
|
+
|
|
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)
|
|
158
|
+
|
|
159
|
+
# Dynamically create a model e.g. "SetupModel"
|
|
160
|
+
m = create_model(
|
|
161
|
+
f"{cls.__name__}",
|
|
162
|
+
__base__=BaseModel,
|
|
163
|
+
__config__=ConfigDict(arbitrary_types_allowed=True),
|
|
164
|
+
**clean_fields,
|
|
165
|
+
)
|
|
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
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Utility protocols for SDK-provided functionality.
|
|
2
|
+
|
|
3
|
+
These protocols are automatically available to all modules and don't need to be
|
|
4
|
+
explicitly included in module output unions.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Any, ClassVar, Literal
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
from digitalkin.models.module.module_types import DataTrigger
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class UtilityProtocol(DataTrigger):
|
|
15
|
+
"""Base class for SDK-provided utility protocols.
|
|
16
|
+
|
|
17
|
+
All SDK utility protocols inherit from this class to enable:
|
|
18
|
+
- Easy identification of SDK vs user-defined protocols
|
|
19
|
+
- Auto-injection capability
|
|
20
|
+
- Consistent behavior across the SDK
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class EndOfStreamOutput(UtilityProtocol):
|
|
25
|
+
"""Signal that the stream has ended."""
|
|
26
|
+
|
|
27
|
+
protocol: Literal["end_of_stream"] = "end_of_stream" # type: ignore
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class HealthcheckPingInput(UtilityProtocol):
|
|
31
|
+
"""Input for healthcheck ping request."""
|
|
32
|
+
|
|
33
|
+
protocol: Literal["healthcheck_ping"] = "healthcheck_ping" # type: ignore
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class HealthcheckPingOutput(UtilityProtocol):
|
|
37
|
+
"""Output for healthcheck ping response.
|
|
38
|
+
|
|
39
|
+
Simple alive check that returns "pong" status.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
protocol: Literal["healthcheck_ping"] = "healthcheck_ping" # type: ignore
|
|
43
|
+
status: Literal["pong"] = "pong"
|
|
44
|
+
latency_ms: float | None = Field(
|
|
45
|
+
default=None,
|
|
46
|
+
description="Round-trip latency in milliseconds",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ServiceHealthStatus(BaseModel):
|
|
51
|
+
"""Health status of a single service."""
|
|
52
|
+
|
|
53
|
+
name: str = Field(..., description="Name of the service")
|
|
54
|
+
status: Literal["healthy", "unhealthy", "unknown"] = Field(
|
|
55
|
+
...,
|
|
56
|
+
description="Health status of the service",
|
|
57
|
+
)
|
|
58
|
+
message: str | None = Field(
|
|
59
|
+
default=None,
|
|
60
|
+
description="Optional message about the service status",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class HealthcheckServicesInput(UtilityProtocol):
|
|
65
|
+
"""Input for healthcheck services request."""
|
|
66
|
+
|
|
67
|
+
protocol: Literal["healthcheck_services"] = "healthcheck_services" # type: ignore
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class HealthcheckServicesOutput(UtilityProtocol):
|
|
71
|
+
"""Output for healthcheck services response.
|
|
72
|
+
|
|
73
|
+
Reports the health status of all configured services.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
protocol: Literal["healthcheck_services"] = "healthcheck_services" # type: ignore
|
|
77
|
+
services: list[ServiceHealthStatus] = Field(
|
|
78
|
+
...,
|
|
79
|
+
description="List of service health statuses",
|
|
80
|
+
)
|
|
81
|
+
overall_status: Literal["healthy", "degraded", "unhealthy"] = Field(
|
|
82
|
+
...,
|
|
83
|
+
description="Overall health status based on all services",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class HealthcheckStatusInput(UtilityProtocol):
|
|
88
|
+
"""Input for healthcheck status request."""
|
|
89
|
+
|
|
90
|
+
protocol: Literal["healthcheck_status"] = "healthcheck_status" # type: ignore
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class HealthcheckStatusOutput(UtilityProtocol):
|
|
94
|
+
"""Output for healthcheck status response.
|
|
95
|
+
|
|
96
|
+
Comprehensive module status including uptime, active jobs, and metadata.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
protocol: Literal["healthcheck_status"] = "healthcheck_status" # type: ignore
|
|
100
|
+
module_name: str = Field(..., description="Name of the module")
|
|
101
|
+
module_status: str = Field(..., description="Current status of the module")
|
|
102
|
+
uptime_seconds: float | None = Field(
|
|
103
|
+
default=None,
|
|
104
|
+
description="Module uptime in seconds",
|
|
105
|
+
)
|
|
106
|
+
active_jobs: int = Field(
|
|
107
|
+
default=0,
|
|
108
|
+
description="Number of currently active jobs",
|
|
109
|
+
)
|
|
110
|
+
metadata: dict[str, Any] = Field(
|
|
111
|
+
default_factory=dict,
|
|
112
|
+
description="Additional metadata about the module",
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class UtilityRegistry:
|
|
117
|
+
"""Registry for SDK-provided built-in triggers.
|
|
118
|
+
|
|
119
|
+
Example:
|
|
120
|
+
builtin_triggers = UtilityRegistry.get_builtin_triggers()
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
_builtin_triggers: ClassVar[tuple | None] = None
|
|
124
|
+
|
|
125
|
+
@classmethod
|
|
126
|
+
def get_builtin_triggers(cls) -> tuple:
|
|
127
|
+
"""Get all SDK-provided built-in trigger handlers.
|
|
128
|
+
|
|
129
|
+
Uses lazy loading to avoid circular imports with the modules package.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
Tuple of TriggerHandler subclasses for built-in functionality.
|
|
133
|
+
"""
|
|
134
|
+
if cls._builtin_triggers is None:
|
|
135
|
+
from digitalkin.modules.triggers.healthcheck_ping_trigger import HealthcheckPingTrigger # noqa: PLC0415
|
|
136
|
+
from digitalkin.modules.triggers.healthcheck_services_trigger import ( # noqa: PLC0415
|
|
137
|
+
HealthcheckServicesTrigger,
|
|
138
|
+
)
|
|
139
|
+
from digitalkin.modules.triggers.healthcheck_status_trigger import HealthcheckStatusTrigger # noqa: PLC0415
|
|
140
|
+
|
|
141
|
+
cls._builtin_triggers = (
|
|
142
|
+
HealthcheckPingTrigger,
|
|
143
|
+
HealthcheckServicesTrigger,
|
|
144
|
+
HealthcheckStatusTrigger,
|
|
145
|
+
)
|
|
146
|
+
return cls._builtin_triggers
|