digitalkin 0.3.1.dev1__py3-none-any.whl → 0.3.2a2__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/server_async_insecure.py +6 -5
- base_server/server_async_secure.py +6 -5
- base_server/server_sync_insecure.py +5 -4
- base_server/server_sync_secure.py +5 -4
- digitalkin/__version__.py +1 -1
- digitalkin/core/job_manager/base_job_manager.py +1 -1
- digitalkin/core/job_manager/single_job_manager.py +78 -36
- digitalkin/core/job_manager/taskiq_broker.py +8 -7
- digitalkin/core/job_manager/taskiq_job_manager.py +9 -5
- digitalkin/core/task_manager/base_task_manager.py +3 -1
- digitalkin/core/task_manager/surrealdb_repository.py +13 -7
- digitalkin/core/task_manager/task_executor.py +27 -10
- digitalkin/core/task_manager/task_session.py +133 -101
- digitalkin/grpc_servers/module_server.py +95 -171
- digitalkin/grpc_servers/module_servicer.py +133 -27
- digitalkin/grpc_servers/utils/grpc_client_wrapper.py +36 -10
- digitalkin/grpc_servers/utils/utility_schema_extender.py +106 -0
- digitalkin/models/__init__.py +1 -1
- digitalkin/models/core/job_manager_models.py +0 -8
- digitalkin/models/core/task_monitor.py +23 -1
- digitalkin/models/grpc_servers/models.py +95 -8
- digitalkin/models/module/__init__.py +26 -13
- digitalkin/models/module/base_types.py +61 -0
- digitalkin/models/module/module_context.py +279 -13
- digitalkin/models/module/module_types.py +29 -109
- digitalkin/models/module/setup_types.py +547 -0
- digitalkin/models/module/tool_cache.py +230 -0
- digitalkin/models/module/tool_reference.py +160 -0
- digitalkin/models/module/utility.py +167 -0
- digitalkin/models/services/cost.py +22 -1
- digitalkin/models/services/registry.py +77 -0
- digitalkin/modules/__init__.py +5 -1
- digitalkin/modules/_base_module.py +253 -90
- digitalkin/modules/archetype_module.py +6 -1
- digitalkin/modules/tool_module.py +6 -1
- digitalkin/modules/triggers/__init__.py +8 -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/services/__init__.py +4 -0
- digitalkin/services/communication/__init__.py +7 -0
- digitalkin/services/communication/communication_strategy.py +87 -0
- digitalkin/services/communication/default_communication.py +104 -0
- digitalkin/services/communication/grpc_communication.py +264 -0
- digitalkin/services/cost/cost_strategy.py +36 -14
- digitalkin/services/cost/default_cost.py +61 -1
- digitalkin/services/cost/grpc_cost.py +98 -2
- digitalkin/services/filesystem/grpc_filesystem.py +9 -2
- digitalkin/services/registry/__init__.py +22 -1
- digitalkin/services/registry/default_registry.py +156 -4
- digitalkin/services/registry/exceptions.py +47 -0
- digitalkin/services/registry/grpc_registry.py +382 -0
- digitalkin/services/registry/registry_models.py +15 -0
- digitalkin/services/registry/registry_strategy.py +106 -4
- digitalkin/services/services_config.py +25 -3
- digitalkin/services/services_models.py +5 -1
- digitalkin/services/setup/default_setup.py +1 -1
- digitalkin/services/setup/grpc_setup.py +1 -1
- digitalkin/services/storage/grpc_storage.py +1 -1
- digitalkin/services/user_profile/__init__.py +11 -0
- digitalkin/services/user_profile/grpc_user_profile.py +2 -2
- digitalkin/services/user_profile/user_profile_strategy.py +0 -15
- digitalkin/utils/__init__.py +40 -0
- digitalkin/utils/conditional_schema.py +260 -0
- digitalkin/utils/dynamic_schema.py +487 -0
- digitalkin/utils/schema_splitter.py +290 -0
- {digitalkin-0.3.1.dev1.dist-info → digitalkin-0.3.2a2.dist-info}/METADATA +13 -13
- digitalkin-0.3.2a2.dist-info/RECORD +144 -0
- {digitalkin-0.3.1.dev1.dist-info → digitalkin-0.3.2a2.dist-info}/WHEEL +1 -1
- {digitalkin-0.3.1.dev1.dist-info → digitalkin-0.3.2a2.dist-info}/top_level.txt +1 -0
- modules/archetype_with_tools_module.py +232 -0
- modules/cpu_intensive_module.py +1 -1
- modules/dynamic_setup_module.py +338 -0
- modules/minimal_llm_module.py +1 -1
- modules/text_transform_module.py +1 -1
- monitoring/digitalkin_observability/__init__.py +46 -0
- monitoring/digitalkin_observability/http_server.py +150 -0
- monitoring/digitalkin_observability/interceptors.py +176 -0
- monitoring/digitalkin_observability/metrics.py +201 -0
- monitoring/digitalkin_observability/prometheus.py +137 -0
- monitoring/tests/test_metrics.py +172 -0
- services/filesystem_module.py +7 -5
- services/storage_module.py +4 -2
- digitalkin/grpc_servers/registry_server.py +0 -65
- digitalkin/grpc_servers/registry_servicer.py +0 -456
- digitalkin-0.3.1.dev1.dist-info/RECORD +0 -117
- {digitalkin-0.3.1.dev1.dist-info → digitalkin-0.3.2a2.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
"""Dynamic schema utilities for runtime value refresh in Pydantic models.
|
|
2
|
+
|
|
3
|
+
This module provides a clean way to mark fields as dynamic using Annotated metadata,
|
|
4
|
+
allowing their schema values to be refreshed at runtime via sync or async fetchers.
|
|
5
|
+
|
|
6
|
+
Example:
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
from digitalkin.utils import DynamicField
|
|
9
|
+
|
|
10
|
+
class AgentSetup(SetupModel):
|
|
11
|
+
model_name: Annotated[str, DynamicField(enum=fetch_models)] = Field(default="gpt-4")
|
|
12
|
+
|
|
13
|
+
See Also:
|
|
14
|
+
- Documentation: docs/api/dynamic_schema.md
|
|
15
|
+
- Tests: tests/utils/test_dynamic_schema.py
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import time
|
|
22
|
+
import traceback
|
|
23
|
+
from collections.abc import Awaitable, Callable
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from itertools import starmap
|
|
26
|
+
from typing import TYPE_CHECKING, Any, TypeVar
|
|
27
|
+
|
|
28
|
+
from digitalkin.logger import logger
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from pydantic.fields import FieldInfo
|
|
32
|
+
|
|
33
|
+
T = TypeVar("T")
|
|
34
|
+
|
|
35
|
+
# Fetcher callable type: sync or async function with no arguments
|
|
36
|
+
Fetcher = Callable[[], T | Awaitable[T]]
|
|
37
|
+
|
|
38
|
+
# Default timeout for fetcher resolution (None = no timeout)
|
|
39
|
+
DEFAULT_TIMEOUT: float | None = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class ResolveResult:
|
|
44
|
+
"""Result of resolving dynamic fetchers.
|
|
45
|
+
|
|
46
|
+
Provides structured access to resolved values and any errors that occurred.
|
|
47
|
+
This allows callers to handle partial failures gracefully.
|
|
48
|
+
|
|
49
|
+
Attributes:
|
|
50
|
+
values: Dict mapping key names to successfully resolved values.
|
|
51
|
+
errors: Dict mapping key names to exceptions that occurred during resolution.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
values: dict[str, Any] = field(default_factory=dict)
|
|
55
|
+
errors: dict[str, Exception] = field(default_factory=dict)
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def success(self) -> bool:
|
|
59
|
+
"""Check if all fetchers resolved successfully.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
True if no errors occurred, False otherwise.
|
|
63
|
+
"""
|
|
64
|
+
return len(self.errors) == 0
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def partial(self) -> bool:
|
|
68
|
+
"""Check if some but not all fetchers succeeded.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
True if there are both values and errors, False otherwise.
|
|
72
|
+
"""
|
|
73
|
+
return len(self.values) > 0 and len(self.errors) > 0
|
|
74
|
+
|
|
75
|
+
def get(self, key: str, default: T | None = None) -> T | None:
|
|
76
|
+
"""Get a resolved value by key.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
key: The fetcher key name.
|
|
80
|
+
default: Default value if key not found or errored.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
The resolved value or default.
|
|
84
|
+
"""
|
|
85
|
+
return self.values.get(key, default) # type: ignore[return-value]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class DynamicField:
|
|
89
|
+
"""Metadata class for Annotated fields with dynamic fetchers.
|
|
90
|
+
|
|
91
|
+
Use with typing.Annotated to mark fields that need runtime value resolution.
|
|
92
|
+
Fetchers are callables (sync or async) that return values at runtime.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
**fetchers: Mapping of key names to fetcher callables.
|
|
96
|
+
Each fetcher is a function (sync or async) that takes no arguments
|
|
97
|
+
and returns the value for that key (e.g., enum values, defaults).
|
|
98
|
+
|
|
99
|
+
Example:
|
|
100
|
+
from typing import Annotated
|
|
101
|
+
|
|
102
|
+
async def fetch_models() -> list[str]:
|
|
103
|
+
return await api.get_models()
|
|
104
|
+
|
|
105
|
+
class Setup(SetupModel):
|
|
106
|
+
model: Annotated[str, DynamicField(enum=fetch_models)] = Field(default="gpt-4")
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
__slots__ = ("fetchers",)
|
|
110
|
+
|
|
111
|
+
def __init__(self, **fetchers: Fetcher[Any]) -> None:
|
|
112
|
+
"""Initialize with fetcher callables."""
|
|
113
|
+
self.fetchers: dict[str, Fetcher[Any]] = fetchers
|
|
114
|
+
|
|
115
|
+
def __repr__(self) -> str:
|
|
116
|
+
"""Return string representation."""
|
|
117
|
+
keys = ", ".join(self.fetchers.keys())
|
|
118
|
+
return f"DynamicField({keys})"
|
|
119
|
+
|
|
120
|
+
def __eq__(self, other: object) -> bool:
|
|
121
|
+
"""Check equality based on fetchers.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
True if fetchers are equal, NotImplemented for non-DynamicField types.
|
|
125
|
+
"""
|
|
126
|
+
if not isinstance(other, DynamicField):
|
|
127
|
+
return NotImplemented
|
|
128
|
+
return self.fetchers == other.fetchers
|
|
129
|
+
|
|
130
|
+
def __hash__(self) -> int:
|
|
131
|
+
"""Hash based on fetcher keys (fetchers themselves aren't hashable).
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
Hash value based on sorted fetcher keys.
|
|
135
|
+
"""
|
|
136
|
+
return hash(tuple(sorted(self.fetchers.keys())))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# Alias for cleaner API: `Dynamic` is shorter than `DynamicField`
|
|
140
|
+
Dynamic = DynamicField
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def get_dynamic_metadata(field_info: FieldInfo) -> DynamicField | None:
|
|
144
|
+
"""Extract DynamicField metadata from a FieldInfo's metadata list.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
field_info: The Pydantic FieldInfo object to inspect.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
The DynamicField metadata instance if found, None otherwise.
|
|
151
|
+
"""
|
|
152
|
+
for meta in field_info.metadata:
|
|
153
|
+
if isinstance(meta, DynamicField):
|
|
154
|
+
return meta
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def has_dynamic(field_info: FieldInfo) -> bool:
|
|
159
|
+
"""Check if a field has DynamicField metadata.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
field_info: The Pydantic FieldInfo object to check.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
True if the field has DynamicField metadata, False otherwise.
|
|
166
|
+
"""
|
|
167
|
+
return get_dynamic_metadata(field_info) is not None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def get_fetchers(field_info: FieldInfo) -> dict[str, Fetcher[Any]]:
|
|
171
|
+
"""Extract fetchers from a field's DynamicField metadata.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
field_info: The Pydantic FieldInfo object to extract from.
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
Dict mapping key names to fetcher callables, empty if no DynamicField metadata.
|
|
178
|
+
"""
|
|
179
|
+
meta = get_dynamic_metadata(field_info)
|
|
180
|
+
if meta is None:
|
|
181
|
+
return {}
|
|
182
|
+
return meta.fetchers
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _get_fetcher_info(fetcher: Fetcher[Any]) -> str:
|
|
186
|
+
"""Get descriptive info about a fetcher for logging.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
fetcher: The fetcher callable.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
A string describing the fetcher (module.name or repr).
|
|
193
|
+
"""
|
|
194
|
+
if hasattr(fetcher, "__module__") and hasattr(fetcher, "__qualname__"):
|
|
195
|
+
return f"{fetcher.__module__}.{fetcher.__qualname__}"
|
|
196
|
+
if hasattr(fetcher, "__name__"):
|
|
197
|
+
return fetcher.__name__
|
|
198
|
+
return repr(fetcher)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
async def _resolve_one(key: str, fetcher: Fetcher[Any]) -> tuple[str, Any]:
|
|
202
|
+
"""Resolve a single fetcher.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
key: The fetcher key name.
|
|
206
|
+
fetcher: The fetcher callable.
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
Tuple of (key, resolved_value).
|
|
210
|
+
|
|
211
|
+
Raises:
|
|
212
|
+
Exception: If the fetcher raises an exception.
|
|
213
|
+
"""
|
|
214
|
+
fetcher_info = _get_fetcher_info(fetcher)
|
|
215
|
+
logger.debug(
|
|
216
|
+
"Resolving fetcher '%s' using %s",
|
|
217
|
+
key,
|
|
218
|
+
fetcher_info,
|
|
219
|
+
extra={"fetcher_key": key, "fetcher": fetcher_info},
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
start_time = time.perf_counter()
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
result = fetcher()
|
|
226
|
+
is_async = asyncio.iscoroutine(result)
|
|
227
|
+
|
|
228
|
+
if is_async:
|
|
229
|
+
logger.debug(
|
|
230
|
+
"Fetcher '%s' returned coroutine, awaiting...",
|
|
231
|
+
key,
|
|
232
|
+
extra={"fetcher_key": key, "is_async": True},
|
|
233
|
+
)
|
|
234
|
+
result = await result
|
|
235
|
+
|
|
236
|
+
except Exception as e:
|
|
237
|
+
elapsed_ms = (time.perf_counter() - start_time) * 1000
|
|
238
|
+
logger.error(
|
|
239
|
+
"Fetcher '%s' (%s) failed after %.2fms: %s: %s",
|
|
240
|
+
key,
|
|
241
|
+
fetcher_info,
|
|
242
|
+
elapsed_ms,
|
|
243
|
+
type(e).__name__,
|
|
244
|
+
str(e) or "(no message)",
|
|
245
|
+
extra={
|
|
246
|
+
"fetcher_key": key,
|
|
247
|
+
"fetcher": fetcher_info,
|
|
248
|
+
"elapsed_ms": elapsed_ms,
|
|
249
|
+
"error_type": type(e).__name__,
|
|
250
|
+
"error_message": str(e),
|
|
251
|
+
"traceback": traceback.format_exc(),
|
|
252
|
+
},
|
|
253
|
+
)
|
|
254
|
+
raise
|
|
255
|
+
|
|
256
|
+
elapsed_ms = (time.perf_counter() - start_time) * 1000
|
|
257
|
+
|
|
258
|
+
logger.debug(
|
|
259
|
+
"Fetcher '%s' resolved successfully in %.2fms, result type: %s",
|
|
260
|
+
key,
|
|
261
|
+
elapsed_ms,
|
|
262
|
+
type(result).__name__,
|
|
263
|
+
extra={
|
|
264
|
+
"fetcher_key": key,
|
|
265
|
+
"elapsed_ms": elapsed_ms,
|
|
266
|
+
"result_type": type(result).__name__,
|
|
267
|
+
},
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
return key, result
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
async def resolve(
|
|
274
|
+
fetchers: dict[str, Fetcher[Any]],
|
|
275
|
+
*,
|
|
276
|
+
timeout: float | None = DEFAULT_TIMEOUT,
|
|
277
|
+
) -> dict[str, Any]:
|
|
278
|
+
"""Resolve all dynamic fetchers to their actual values in parallel.
|
|
279
|
+
|
|
280
|
+
Fetchers are executed concurrently using asyncio.gather() for better
|
|
281
|
+
performance when multiple async fetchers are involved.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
fetchers: Dict mapping key names to fetcher callables.
|
|
285
|
+
timeout: Optional timeout in seconds for all fetchers combined.
|
|
286
|
+
If None (default), no timeout is applied.
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
Dict mapping key names to resolved values.
|
|
290
|
+
|
|
291
|
+
Raises:
|
|
292
|
+
asyncio.TimeoutError: If timeout is exceeded.
|
|
293
|
+
Exception: If any fetcher raises an exception, it is propagated.
|
|
294
|
+
|
|
295
|
+
Example:
|
|
296
|
+
fetchers = {"enum": fetch_models, "default": get_default}
|
|
297
|
+
resolved = await resolve(fetchers, timeout=5.0)
|
|
298
|
+
# resolved = {"enum": ["gpt-4", "gpt-3.5"], "default": "gpt-4"}
|
|
299
|
+
"""
|
|
300
|
+
if not fetchers:
|
|
301
|
+
logger.debug("resolve() called with empty fetchers, returning {}")
|
|
302
|
+
return {}
|
|
303
|
+
|
|
304
|
+
fetcher_keys = list(fetchers.keys())
|
|
305
|
+
fetcher_infos = {k: _get_fetcher_info(f) for k, f in fetchers.items()}
|
|
306
|
+
|
|
307
|
+
logger.info(
|
|
308
|
+
"resolve() starting parallel resolution of %d fetcher(s): %s",
|
|
309
|
+
len(fetchers),
|
|
310
|
+
fetcher_keys,
|
|
311
|
+
extra={
|
|
312
|
+
"fetcher_count": len(fetchers),
|
|
313
|
+
"fetcher_keys": fetcher_keys,
|
|
314
|
+
"fetcher_infos": fetcher_infos,
|
|
315
|
+
"timeout": timeout,
|
|
316
|
+
},
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
start_time = time.perf_counter()
|
|
320
|
+
|
|
321
|
+
# Create tasks for parallel execution
|
|
322
|
+
tasks = list(starmap(_resolve_one, fetchers.items()))
|
|
323
|
+
|
|
324
|
+
# Execute with optional timeout
|
|
325
|
+
try:
|
|
326
|
+
if timeout is not None:
|
|
327
|
+
results = await asyncio.wait_for(asyncio.gather(*tasks), timeout=timeout)
|
|
328
|
+
else:
|
|
329
|
+
results = await asyncio.gather(*tasks)
|
|
330
|
+
except asyncio.TimeoutError:
|
|
331
|
+
elapsed_ms = (time.perf_counter() - start_time) * 1000
|
|
332
|
+
logger.error(
|
|
333
|
+
"resolve() timed out after %.2fms (timeout=%.2fs)",
|
|
334
|
+
elapsed_ms,
|
|
335
|
+
timeout,
|
|
336
|
+
extra={"elapsed_ms": elapsed_ms, "timeout": timeout},
|
|
337
|
+
)
|
|
338
|
+
raise
|
|
339
|
+
|
|
340
|
+
elapsed_ms = (time.perf_counter() - start_time) * 1000
|
|
341
|
+
logger.info(
|
|
342
|
+
"resolve() completed successfully in %.2fms, resolved %d fetcher(s)",
|
|
343
|
+
elapsed_ms,
|
|
344
|
+
len(results),
|
|
345
|
+
extra={"elapsed_ms": elapsed_ms, "resolved_count": len(results)},
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
return dict(results)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
async def resolve_safe(
|
|
352
|
+
fetchers: dict[str, Fetcher[Any]],
|
|
353
|
+
*,
|
|
354
|
+
timeout: float | None = DEFAULT_TIMEOUT,
|
|
355
|
+
) -> ResolveResult:
|
|
356
|
+
"""Resolve fetchers with structured error handling.
|
|
357
|
+
|
|
358
|
+
Unlike `resolve()`, this function catches individual fetcher errors
|
|
359
|
+
and returns them in a structured result, allowing partial success.
|
|
360
|
+
|
|
361
|
+
Args:
|
|
362
|
+
fetchers: Dict mapping key names to fetcher callables.
|
|
363
|
+
timeout: Optional timeout in seconds for all fetchers combined.
|
|
364
|
+
If None (default), no timeout is applied. Note: timeout applies
|
|
365
|
+
to the entire operation, not individual fetchers.
|
|
366
|
+
|
|
367
|
+
Returns:
|
|
368
|
+
ResolveResult with values and any errors that occurred.
|
|
369
|
+
|
|
370
|
+
Example:
|
|
371
|
+
result = await resolve_safe(fetchers, timeout=5.0)
|
|
372
|
+
if result.success:
|
|
373
|
+
print("All resolved:", result.values)
|
|
374
|
+
elif result.partial:
|
|
375
|
+
print("Partial success:", result.values)
|
|
376
|
+
print("Errors:", result.errors)
|
|
377
|
+
else:
|
|
378
|
+
print("All failed:", result.errors)
|
|
379
|
+
"""
|
|
380
|
+
if not fetchers:
|
|
381
|
+
logger.debug("resolve_safe() called with empty fetchers, returning empty ResolveResult")
|
|
382
|
+
return ResolveResult()
|
|
383
|
+
|
|
384
|
+
fetcher_keys = list(fetchers.keys())
|
|
385
|
+
fetcher_infos = {k: _get_fetcher_info(f) for k, f in fetchers.items()}
|
|
386
|
+
|
|
387
|
+
logger.info(
|
|
388
|
+
"resolve_safe() starting parallel resolution of %d fetcher(s): %s",
|
|
389
|
+
len(fetchers),
|
|
390
|
+
fetcher_keys,
|
|
391
|
+
extra={
|
|
392
|
+
"fetcher_count": len(fetchers),
|
|
393
|
+
"fetcher_keys": fetcher_keys,
|
|
394
|
+
"fetcher_infos": fetcher_infos,
|
|
395
|
+
"timeout": timeout,
|
|
396
|
+
},
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
start_time = time.perf_counter()
|
|
400
|
+
result = ResolveResult()
|
|
401
|
+
|
|
402
|
+
async def safe_resolve_one(key: str, fetcher: Fetcher[Any]) -> None:
|
|
403
|
+
"""Resolve one fetcher, capturing errors."""
|
|
404
|
+
try:
|
|
405
|
+
_, value = await _resolve_one(key, fetcher)
|
|
406
|
+
result.values[key] = value
|
|
407
|
+
except Exception as e:
|
|
408
|
+
# Error already logged in _resolve_one, just capture it
|
|
409
|
+
result.errors[key] = e
|
|
410
|
+
|
|
411
|
+
# Create tasks for parallel execution
|
|
412
|
+
tasks = list(starmap(safe_resolve_one, fetchers.items()))
|
|
413
|
+
|
|
414
|
+
try:
|
|
415
|
+
if timeout is not None:
|
|
416
|
+
await asyncio.wait_for(asyncio.gather(*tasks), timeout=timeout)
|
|
417
|
+
else:
|
|
418
|
+
await asyncio.gather(*tasks)
|
|
419
|
+
except asyncio.TimeoutError as e:
|
|
420
|
+
elapsed_ms = (time.perf_counter() - start_time) * 1000
|
|
421
|
+
# Add timeout error for any keys that didn't complete
|
|
422
|
+
resolved_keys = set(result.values.keys()) | set(result.errors.keys())
|
|
423
|
+
timed_out_keys = [key for key in fetchers if key not in resolved_keys]
|
|
424
|
+
for key in timed_out_keys:
|
|
425
|
+
result.errors[key] = e
|
|
426
|
+
|
|
427
|
+
logger.error(
|
|
428
|
+
"resolve_safe() timed out after %.2fms (timeout=%.2fs), %d succeeded, %d failed, %d timed out",
|
|
429
|
+
elapsed_ms,
|
|
430
|
+
timeout,
|
|
431
|
+
len(result.values),
|
|
432
|
+
len(result.errors) - len(timed_out_keys),
|
|
433
|
+
len(timed_out_keys),
|
|
434
|
+
extra={
|
|
435
|
+
"elapsed_ms": elapsed_ms,
|
|
436
|
+
"timeout": timeout,
|
|
437
|
+
"succeeded_keys": list(result.values.keys()),
|
|
438
|
+
"failed_keys": [k for k in result.errors if k not in timed_out_keys],
|
|
439
|
+
"timed_out_keys": timed_out_keys,
|
|
440
|
+
},
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
elapsed_ms = (time.perf_counter() - start_time) * 1000
|
|
444
|
+
|
|
445
|
+
# Log summary
|
|
446
|
+
if result.success:
|
|
447
|
+
logger.info(
|
|
448
|
+
"resolve_safe() completed successfully in %.2fms, all %d fetcher(s) resolved",
|
|
449
|
+
elapsed_ms,
|
|
450
|
+
len(result.values),
|
|
451
|
+
extra={
|
|
452
|
+
"elapsed_ms": elapsed_ms,
|
|
453
|
+
"success": True,
|
|
454
|
+
"resolved_count": len(result.values),
|
|
455
|
+
},
|
|
456
|
+
)
|
|
457
|
+
elif result.partial:
|
|
458
|
+
logger.warning(
|
|
459
|
+
"resolve_safe() completed with partial success in %.2fms: %d succeeded, %d failed",
|
|
460
|
+
elapsed_ms,
|
|
461
|
+
len(result.values),
|
|
462
|
+
len(result.errors),
|
|
463
|
+
extra={
|
|
464
|
+
"elapsed_ms": elapsed_ms,
|
|
465
|
+
"success": False,
|
|
466
|
+
"partial": True,
|
|
467
|
+
"resolved_count": len(result.values),
|
|
468
|
+
"error_count": len(result.errors),
|
|
469
|
+
"succeeded_keys": list(result.values.keys()),
|
|
470
|
+
"failed_keys": list(result.errors.keys()),
|
|
471
|
+
},
|
|
472
|
+
)
|
|
473
|
+
else:
|
|
474
|
+
logger.error(
|
|
475
|
+
"resolve_safe() completed with all failures in %.2fms: %d failed",
|
|
476
|
+
elapsed_ms,
|
|
477
|
+
len(result.errors),
|
|
478
|
+
extra={
|
|
479
|
+
"elapsed_ms": elapsed_ms,
|
|
480
|
+
"success": False,
|
|
481
|
+
"partial": False,
|
|
482
|
+
"error_count": len(result.errors),
|
|
483
|
+
"failed_keys": list(result.errors.keys()),
|
|
484
|
+
},
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
return result
|