digitalkin 0.3.2.dev18__py3-none-any.whl → 0.3.2.dev20__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.
digitalkin/__version__.py CHANGED
@@ -5,4 +5,4 @@ from importlib.metadata import PackageNotFoundError, version
5
5
  try:
6
6
  __version__ = version("digitalkin")
7
7
  except PackageNotFoundError:
8
- __version__ = "0.3.2.dev18"
8
+ __version__ = "0.3.2.dev20"
@@ -17,7 +17,7 @@ from digitalkin.core.job_manager.base_job_manager import BaseJobManager
17
17
  from digitalkin.grpc_servers.utils.exceptions import ServicerError
18
18
  from digitalkin.logger import logger
19
19
  from digitalkin.models.core.job_manager_models import JobManagerMode
20
- from digitalkin.models.module.module import ModuleStatus
20
+ from digitalkin.models.module.module import ModuleCodeModel, ModuleStatus
21
21
  from digitalkin.modules._base_module import BaseModule
22
22
  from digitalkin.services.registry import GrpcRegistry, RegistryStrategy
23
23
  from digitalkin.services.services_models import ServicesMode
@@ -159,7 +159,32 @@ class ModuleServicer(module_service_pb2_grpc.ModuleServiceServicer, ArgParser):
159
159
  return lifecycle_pb2.ConfigSetupModuleResponse(success=False)
160
160
 
161
161
  updated_setup_data = await self.job_manager.generate_config_setup_module_response(job_id)
162
- logger.info("Setup updated", extra={"job_id": job_id})
162
+ logger.info("Setup response received", extra={"job_id": job_id})
163
+
164
+ # Check if response is an error
165
+ if isinstance(updated_setup_data, ModuleCodeModel):
166
+ logger.error(
167
+ "Config setup failed",
168
+ extra={"job_id": job_id, "code": updated_setup_data.code, "message": updated_setup_data.message},
169
+ )
170
+ context.set_code(grpc.StatusCode.INTERNAL)
171
+ context.set_details(updated_setup_data.message or "Config setup failed")
172
+ return lifecycle_pb2.ConfigSetupModuleResponse(success=False)
173
+
174
+ if isinstance(updated_setup_data, dict) and "code" in updated_setup_data:
175
+ # ModuleCodeModel was serialized to dict
176
+ logger.error(
177
+ "Config setup failed",
178
+ extra={
179
+ "job_id": job_id,
180
+ "code": updated_setup_data["code"],
181
+ "message": updated_setup_data.get("message"),
182
+ },
183
+ )
184
+ context.set_code(grpc.StatusCode.INTERNAL)
185
+ context.set_details(updated_setup_data.get("message") or "Config setup failed")
186
+ return lifecycle_pb2.ConfigSetupModuleResponse(success=False)
187
+
163
188
  logger.debug("Updated setup data", extra={"job_id": job_id, "setup_data": updated_setup_data})
164
189
  setup_version.content = json_format.ParseDict(
165
190
  updated_setup_data,
@@ -29,65 +29,12 @@ SetupModelT = TypeVar("SetupModelT", bound="SetupModel")
29
29
  class SetupModel(BaseModel, Generic[SetupModelT]):
30
30
  """Base setup model with dynamic schema and tool cache support."""
31
31
 
32
+ model_config = ConfigDict(extra="allow")
32
33
  _clean_model_cache: ClassVar[dict[tuple[type, bool, bool], type]] = {}
33
-
34
- def __init_subclass__(cls, **kwargs: Any) -> None: # noqa: ANN401
35
- """Inject hidden companion fields for ToolReference annotations.
36
-
37
- Args:
38
- **kwargs: Keyword arguments passed to parent.
39
- """
40
- super().__init_subclass__(**kwargs)
41
- cls._inject_tool_cache_fields()
42
-
43
- @classmethod
44
- def _inject_tool_cache_fields(cls) -> None:
45
- """Inject hidden companion fields for ToolReference annotations."""
46
- annotations = getattr(cls, "__annotations__", {})
47
- new_annotations: dict[str, Any] = {}
48
-
49
- for field_name, annotation in annotations.items():
50
- if cls._is_tool_reference_annotation(annotation):
51
- cache_field_name = f"{field_name}_cache"
52
- if cache_field_name not in annotations:
53
- # Check if it's a list type
54
- origin = get_origin(annotation)
55
- if origin is list:
56
- new_annotations[cache_field_name] = list[ToolModuleInfo]
57
- setattr(
58
- cls,
59
- cache_field_name,
60
- Field(default_factory=list, json_schema_extra={"hidden": True}),
61
- )
62
- else:
63
- new_annotations[cache_field_name] = ToolModuleInfo | None
64
- setattr(
65
- cls,
66
- cache_field_name,
67
- Field(default=None, json_schema_extra={"hidden": True}),
68
- )
69
-
70
- if new_annotations:
71
- cls.__annotations__ = {**annotations, **new_annotations}
72
-
73
- @classmethod
74
- def _is_tool_reference_annotation(cls, annotation: object) -> bool:
75
- """Check if annotation is ToolReference or Optional[ToolReference].
76
-
77
- Args:
78
- annotation: Type annotation to check.
79
-
80
- Returns:
81
- True if annotation is or contains ToolReference.
82
- """
83
- origin = get_origin(annotation)
84
- if origin is typing.Union or origin is types.UnionType:
85
- return any(
86
- arg is ToolReference or (isinstance(arg, type) and issubclass(arg, ToolReference))
87
- for arg in get_args(annotation)
88
- if arg is not type(None)
89
- )
90
- return annotation is ToolReference or (isinstance(annotation, type) and issubclass(annotation, ToolReference))
34
+ resolved_tools: dict[str, ToolModuleInfo] = Field(
35
+ default_factory=dict,
36
+ json_schema_extra={"hidden": True},
37
+ )
91
38
 
92
39
  @classmethod
93
40
  async def get_clean_model(
@@ -285,7 +232,7 @@ class SetupModel(BaseModel, Generic[SetupModelT]):
285
232
  if not has_changes:
286
233
  return model_cls
287
234
 
288
- root_extra = cls.model_config.get("json_schema_extra", {})
235
+ root_extra = model_cls.model_config.get("json_schema_extra", {})
289
236
 
290
237
  return create_model(
291
238
  model_cls.__name__,
@@ -346,7 +293,12 @@ class SetupModel(BaseModel, Generic[SetupModelT]):
346
293
  communication: Communication service for module schemas.
347
294
  """
348
295
  logger.info("Starting resolve_tool_references")
349
- await self._resolve_tool_references_recursive(self, registry, communication)
296
+ await self._resolve_tool_references_recursive(
297
+ self,
298
+ registry,
299
+ communication,
300
+ self.resolved_tools,
301
+ )
350
302
  logger.info("Finished resolve_tool_references")
351
303
 
352
304
  @classmethod
@@ -355,6 +307,7 @@ class SetupModel(BaseModel, Generic[SetupModelT]):
355
307
  model_instance: BaseModel,
356
308
  registry: "RegistryStrategy",
357
309
  communication: "CommunicationStrategy",
310
+ resolved_tools: dict[str, ToolModuleInfo],
358
311
  ) -> None:
359
312
  """Recursively resolve ToolReference fields in a model.
360
313
 
@@ -362,11 +315,18 @@ class SetupModel(BaseModel, Generic[SetupModelT]):
362
315
  model_instance: Model instance to process.
363
316
  registry: Registry service for resolution.
364
317
  communication: Communication service for module schemas.
318
+ resolved_tools: Cache of already resolved tools.
365
319
  """
366
320
  for field_name, field_value in model_instance.__dict__.items():
367
321
  if field_value is None:
368
322
  continue
369
- await cls._resolve_field_value(field_name, field_value, registry, communication)
323
+ await cls._resolve_field_value(
324
+ field_name,
325
+ field_value,
326
+ registry,
327
+ communication,
328
+ resolved_tools,
329
+ )
370
330
 
371
331
  @classmethod
372
332
  async def _resolve_field_value(
@@ -375,6 +335,7 @@ class SetupModel(BaseModel, Generic[SetupModelT]):
375
335
  field_value: "BaseModel | ToolReference | list | dict",
376
336
  registry: "RegistryStrategy",
377
337
  communication: "CommunicationStrategy",
338
+ resolved_tools: dict[str, ToolModuleInfo],
378
339
  ) -> None:
379
340
  """Resolve a single field value based on its type.
380
341
 
@@ -383,15 +344,37 @@ class SetupModel(BaseModel, Generic[SetupModelT]):
383
344
  field_value: Value to process.
384
345
  registry: Registry service for resolution.
385
346
  communication: Communication service for module schemas.
347
+ resolved_tools: Cache of already resolved tools.
386
348
  """
387
349
  if isinstance(field_value, ToolReference):
388
- await cls._resolve_single_tool_reference(field_name, field_value, registry, communication)
350
+ await cls._resolve_single_tool_reference(
351
+ field_name,
352
+ field_value,
353
+ registry,
354
+ communication,
355
+ resolved_tools,
356
+ )
389
357
  elif isinstance(field_value, BaseModel):
390
- await cls._resolve_tool_references_recursive(field_value, registry, communication)
358
+ await cls._resolve_tool_references_recursive(
359
+ field_value,
360
+ registry,
361
+ communication,
362
+ resolved_tools,
363
+ )
391
364
  elif isinstance(field_value, list):
392
- await cls._resolve_list_items(field_value, registry, communication)
365
+ await cls._resolve_list_items(
366
+ field_value,
367
+ registry,
368
+ communication,
369
+ resolved_tools,
370
+ )
393
371
  elif isinstance(field_value, dict):
394
- await cls._resolve_dict_values(field_value, registry, communication)
372
+ await cls._resolve_dict_values(
373
+ field_value,
374
+ registry,
375
+ communication,
376
+ resolved_tools,
377
+ )
395
378
 
396
379
  @classmethod
397
380
  async def _resolve_single_tool_reference(
@@ -400,6 +383,7 @@ class SetupModel(BaseModel, Generic[SetupModelT]):
400
383
  tool_ref: ToolReference,
401
384
  registry: "RegistryStrategy",
402
385
  communication: "CommunicationStrategy",
386
+ resolved_tools: dict[str, ToolModuleInfo],
403
387
  ) -> None:
404
388
  """Resolve a single ToolReference.
405
389
 
@@ -408,17 +392,33 @@ class SetupModel(BaseModel, Generic[SetupModelT]):
408
392
  tool_ref: ToolReference to resolve.
409
393
  registry: Registry service for resolution.
410
394
  communication: Communication service for module schemas.
395
+ resolved_tools: Cache of already resolved tools.
411
396
  """
412
397
  logger.info("Resolving ToolReference '%s' with setup_id='%s'", field_name, tool_ref.config.setup_id)
398
+
399
+ slug = tool_ref.slug
400
+ if slug:
401
+ cached = resolved_tools.get(slug)
402
+ if cached:
403
+ tool_ref._cached_info = cached # noqa: SLF001
404
+ logger.info("ToolReference '%s' resolved from cache -> %s", field_name, cached)
405
+ return
406
+
413
407
  try:
414
- await tool_ref.resolve(registry, communication)
408
+ info = await tool_ref.resolve(registry, communication)
409
+ if info and info.setup_id:
410
+ resolved_tools[info.setup_id] = info
415
411
  logger.info("Resolved ToolReference '%s' -> %s", field_name, tool_ref.tool_module_info)
416
412
  except Exception:
417
413
  logger.exception("Failed to resolve ToolReference '%s'", field_name)
418
414
 
419
415
  @classmethod
420
416
  async def _resolve_list_items(
421
- cls, items: list, registry: "RegistryStrategy", communication: "CommunicationStrategy"
417
+ cls,
418
+ items: list,
419
+ registry: "RegistryStrategy",
420
+ communication: "CommunicationStrategy",
421
+ resolved_tools: dict[str, ToolModuleInfo],
422
422
  ) -> None:
423
423
  """Resolve ToolReference instances in a list.
424
424
 
@@ -426,16 +426,32 @@ class SetupModel(BaseModel, Generic[SetupModelT]):
426
426
  items: List of items to process.
427
427
  registry: Registry service for resolution.
428
428
  communication: Communication service for module schemas.
429
+ resolved_tools: Cache of already resolved tools.
429
430
  """
430
431
  for item in items:
431
432
  if isinstance(item, ToolReference):
432
- await cls._resolve_single_tool_reference("list_item", item, registry, communication)
433
+ await cls._resolve_single_tool_reference(
434
+ "list_item",
435
+ item,
436
+ registry,
437
+ communication,
438
+ resolved_tools,
439
+ )
433
440
  elif isinstance(item, BaseModel):
434
- await cls._resolve_tool_references_recursive(item, registry, communication)
441
+ await cls._resolve_tool_references_recursive(
442
+ item,
443
+ registry,
444
+ communication,
445
+ resolved_tools,
446
+ )
435
447
 
436
448
  @classmethod
437
449
  async def _resolve_dict_values(
438
- cls, mapping: dict, registry: "RegistryStrategy", communication: "CommunicationStrategy"
450
+ cls,
451
+ mapping: dict,
452
+ registry: "RegistryStrategy",
453
+ communication: "CommunicationStrategy",
454
+ resolved_tools: dict[str, ToolModuleInfo],
439
455
  ) -> None:
440
456
  """Resolve ToolReference instances in dict values.
441
457
 
@@ -443,63 +459,86 @@ class SetupModel(BaseModel, Generic[SetupModelT]):
443
459
  mapping: Dict to process.
444
460
  registry: Registry service for resolution.
445
461
  communication: Communication service for module schemas.
462
+ resolved_tools: Cache of already resolved tools.
446
463
  """
447
464
  for item in mapping.values():
448
465
  if isinstance(item, ToolReference):
449
- await cls._resolve_single_tool_reference("dict_value", item, registry, communication)
466
+ await cls._resolve_single_tool_reference(
467
+ "dict_value",
468
+ item,
469
+ registry,
470
+ communication,
471
+ resolved_tools,
472
+ )
450
473
  elif isinstance(item, BaseModel):
451
- await cls._resolve_tool_references_recursive(item, registry, communication)
474
+ await cls._resolve_tool_references_recursive(
475
+ item,
476
+ registry,
477
+ communication,
478
+ resolved_tools,
479
+ )
452
480
 
453
481
  def build_tool_cache(self) -> ToolCache:
454
- """Build tool cache from resolved ToolReferences, populating companion fields.
482
+ """Build tool cache from resolved ToolReferences.
455
483
 
456
484
  Returns:
457
485
  ToolCache with field names as keys and ToolModuleInfo as values.
458
486
  """
459
- logger.info("Building tool cache")
460
487
  cache = ToolCache()
461
488
  self._build_tool_cache_recursive(self, cache)
462
489
  logger.info("Tool cache built: %d entries", len(cache.entries))
463
490
  return cache
464
491
 
465
- def _build_tool_cache_recursive(self, model_instance: BaseModel, cache: ToolCache) -> None: # noqa: C901
466
- """Recursively build tool cache and populate companion fields.
492
+ def _build_tool_cache_recursive(self, model_instance: BaseModel, cache: ToolCache) -> None:
493
+ """Recursively build tool cache from ToolReferences.
467
494
 
468
495
  Args:
469
496
  model_instance: Model instance to process.
470
497
  cache: ToolCache to populate.
471
498
  """
472
- for field_name, field_value in model_instance.__dict__.items():
499
+ for field_value in model_instance.__dict__.values():
473
500
  if field_value is None:
474
501
  continue
475
502
  if isinstance(field_value, ToolReference):
476
- cache_field_name = f"{field_name}_cache"
477
-
478
- cached_info = getattr(model_instance, cache_field_name, None)
479
- module_info = field_value.tool_module_info or cached_info
503
+ module_info = self.resolved_tools.get(field_value.slug or "") or field_value.tool_module_info
480
504
  if module_info:
481
- if not cached_info:
482
- setattr(model_instance, cache_field_name, module_info)
505
+ self.resolved_tools[module_info.setup_id] = module_info
483
506
  cache.add(module_info.module_id, module_info)
484
- logger.debug("Added tool to cache: %s", module_info.module_id)
485
507
  elif isinstance(field_value, BaseModel):
486
508
  self._build_tool_cache_recursive(field_value, cache)
487
509
  elif isinstance(field_value, list):
488
- cache_field_name = f"{field_name}_cache"
489
- cached_infos = getattr(model_instance, cache_field_name, None) or []
490
- resolved_infos: list[ToolModuleInfo] = []
491
-
492
- for idx, item in enumerate(field_value):
493
- if isinstance(item, ToolReference):
494
- # Use resolved info or fallback to cached
495
- module_info = item.tool_module_info or (cached_infos[idx] if idx < len(cached_infos) else None)
496
- if module_info:
497
- resolved_infos.append(module_info)
498
- cache.add(module_info.module_id, module_info)
499
- logger.debug("Added tool to cache: %s", module_info.module_id)
500
- elif isinstance(item, BaseModel):
501
- self._build_tool_cache_recursive(item, cache)
502
-
503
- # Update companion field with resolved infos
504
- if resolved_infos:
505
- setattr(model_instance, cache_field_name, resolved_infos)
510
+ self._process_list_items(field_value, cache)
511
+ elif isinstance(field_value, dict):
512
+ self._process_dict_values(field_value, cache)
513
+
514
+ def _process_list_items(self, items: list, cache: ToolCache) -> None:
515
+ """Process list items for ToolReferences.
516
+
517
+ Args:
518
+ items: List to process.
519
+ cache: ToolCache to populate.
520
+ """
521
+ for item in items:
522
+ if isinstance(item, ToolReference):
523
+ module_info = self.resolved_tools.get(item.slug or "") or item.tool_module_info
524
+ if module_info:
525
+ self.resolved_tools[module_info.setup_id] = module_info
526
+ cache.add(module_info.module_id, module_info)
527
+ elif isinstance(item, BaseModel):
528
+ self._build_tool_cache_recursive(item, cache)
529
+
530
+ def _process_dict_values(self, mapping: dict, cache: ToolCache) -> None:
531
+ """Process dict values for ToolReferences.
532
+
533
+ Args:
534
+ mapping: Dict to process.
535
+ cache: ToolCache to populate.
536
+ """
537
+ for item in mapping.values():
538
+ if isinstance(item, ToolReference):
539
+ module_info = self.resolved_tools.get(item.slug or "") or item.tool_module_info
540
+ if module_info:
541
+ self.resolved_tools[module_info.setup_id] = module_info
542
+ cache.add(module_info.module_id, module_info)
543
+ elif isinstance(item, BaseModel):
544
+ self._build_tool_cache_recursive(item, cache)
@@ -556,21 +556,7 @@ class BaseModule( # noqa: PLR0904
556
556
  await self._resolve_tools(config_setup_data)
557
557
  updated_config = await self.run_config_setup(self.context, config_setup_data)
558
558
 
559
- # Build wrapper: original structure with updated content
560
- wrapper = config_setup_data.model_dump()
561
- wrapper["content"] = updated_config.model_dump()
562
-
563
- # Debug logging
564
- content = wrapper.get("content", {})
565
- logger.info(
566
- "Config setup wrapper: keys=%s, content_keys=%s, tools_cache=%s",
567
- list(wrapper.keys()),
568
- list(content.keys()) if isinstance(content, dict) else "N/A",
569
- content.get("tools_cache") if isinstance(content, dict) else "N/A",
570
- extra=self.context.session.current_ids(),
571
- )
572
-
573
- setup_model = await self.create_setup_model(wrapper)
559
+ setup_model = await self.create_setup_model(updated_config.model_dump())
574
560
  await callback(setup_model)
575
561
  self._status = ModuleStatus.STOPPING
576
562
  except Exception:
@@ -1,5 +1,12 @@
1
1
  """General utils folder."""
2
2
 
3
+ from digitalkin.utils.conditional_schema import (
4
+ Conditional,
5
+ ConditionalField,
6
+ ConditionalSchemaMixin,
7
+ get_conditional_metadata,
8
+ has_conditional,
9
+ )
3
10
  from digitalkin.utils.dynamic_schema import (
4
11
  DEFAULT_TIMEOUT,
5
12
  Dynamic,
@@ -14,13 +21,20 @@ from digitalkin.utils.dynamic_schema import (
14
21
  )
15
22
 
16
23
  __all__ = [
24
+ # Dynamic schema
17
25
  "DEFAULT_TIMEOUT",
26
+ # Conditional schema
27
+ "Conditional",
28
+ "ConditionalField",
29
+ "ConditionalSchemaMixin",
18
30
  "Dynamic",
19
31
  "DynamicField",
20
32
  "Fetcher",
21
33
  "ResolveResult",
34
+ "get_conditional_metadata",
22
35
  "get_dynamic_metadata",
23
36
  "get_fetchers",
37
+ "has_conditional",
24
38
  "has_dynamic",
25
39
  "resolve",
26
40
  "resolve_safe",
@@ -0,0 +1,260 @@
1
+ """Conditional field visibility for react-jsonschema-form.
2
+
3
+ This module provides a clean way to mark fields as conditional using Annotated metadata,
4
+ generating JSON Schema with if/then clauses for react-jsonschema-form.
5
+
6
+ Example:
7
+ from typing import Annotated, Literal
8
+ from pydantic import BaseModel, Field
9
+ from digitalkin.utils import Conditional, ConditionalSchemaMixin
10
+
11
+ class Tools(ConditionalSchemaMixin, BaseModel):
12
+ web_search_enabled: bool = Field(...)
13
+
14
+ web_search_engine: Annotated[
15
+ Literal["duckduckgo", "tavily"],
16
+ Conditional(trigger="web_search_enabled", show_when=True),
17
+ ] = Field(...)
18
+
19
+ See Also:
20
+ - Documentation: docs/api/conditional_schema.md
21
+ - Tests: tests/utils/test_conditional_schema.py
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from dataclasses import dataclass
27
+ from typing import TYPE_CHECKING, Any, ClassVar
28
+
29
+ from pydantic import BaseModel
30
+
31
+ if TYPE_CHECKING:
32
+ from pydantic.annotated_handlers import GetJsonSchemaHandler
33
+ from pydantic.fields import FieldInfo
34
+ from pydantic.json_schema import JsonSchemaValue
35
+ from pydantic_core.core_schema import CoreSchema
36
+
37
+
38
+ @dataclass
39
+ class ConditionalField:
40
+ """Metadata for conditional field visibility.
41
+
42
+ Use with typing.Annotated to mark fields that should only appear
43
+ when a trigger field has a specific value.
44
+
45
+ Args:
46
+ trigger: Name of the field that controls visibility.
47
+ show_when: Value(s) that trigger field must have to show this field.
48
+ Can be a boolean, string, or list of strings for multiple values.
49
+ required_when_shown: Whether field is required when visible. Defaults to True.
50
+
51
+ Example:
52
+ # Boolean condition
53
+ web_search_engine: Annotated[
54
+ str,
55
+ Conditional(trigger="web_search_enabled", show_when=True),
56
+ ] = Field(...)
57
+
58
+ # Enum condition
59
+ advanced_option: Annotated[
60
+ str,
61
+ Conditional(trigger="mode", show_when="advanced"),
62
+ ] = Field(...)
63
+
64
+ # Multiple values condition
65
+ shared_feature: Annotated[
66
+ bool,
67
+ Conditional(trigger="mode", show_when=["standard", "advanced"]),
68
+ ] = Field(...)
69
+ """
70
+
71
+ trigger: str
72
+ show_when: bool | str | list[str]
73
+ required_when_shown: bool = True
74
+
75
+ def __post_init__(self) -> None:
76
+ """Normalize single-item lists to scalar values."""
77
+ if isinstance(self.show_when, list) and len(self.show_when) == 1:
78
+ self.show_when = self.show_when[0]
79
+
80
+
81
+ # Short alias for cleaner API
82
+ Conditional = ConditionalField
83
+
84
+
85
+ def get_conditional_metadata(field_info: FieldInfo) -> ConditionalField | None:
86
+ """Extract ConditionalField from field metadata.
87
+
88
+ Args:
89
+ field_info: The Pydantic FieldInfo object to inspect.
90
+
91
+ Returns:
92
+ The ConditionalField metadata instance if found, None otherwise.
93
+ """
94
+ for meta in field_info.metadata:
95
+ if isinstance(meta, ConditionalField):
96
+ return meta
97
+ return None
98
+
99
+
100
+ def has_conditional(field_info: FieldInfo) -> bool:
101
+ """Check if field has ConditionalField metadata.
102
+
103
+ Args:
104
+ field_info: The Pydantic FieldInfo object to check.
105
+
106
+ Returns:
107
+ True if the field has ConditionalField metadata, False otherwise.
108
+ """
109
+ return get_conditional_metadata(field_info) is not None
110
+
111
+
112
+ def _collect_conditions(
113
+ model_fields: dict[str, FieldInfo],
114
+ props: dict[str, Any],
115
+ ) -> tuple[dict[tuple[str, Any], list[tuple[str, bool]]], set[str]]:
116
+ """Collect conditional fields grouped by trigger and show_when value.
117
+
118
+ Args:
119
+ model_fields: The model's field definitions.
120
+ props: The schema properties dict.
121
+
122
+ Returns:
123
+ Tuple of (conditions dict, fields to remove set).
124
+ """
125
+ conditions: dict[tuple[str, Any], list[tuple[str, bool]]] = {}
126
+ fields_to_remove: set[str] = set()
127
+
128
+ for field_name, field_info in model_fields.items():
129
+ cond = get_conditional_metadata(field_info)
130
+ if cond is None or field_name not in props:
131
+ continue
132
+
133
+ show_key = tuple(cond.show_when) if isinstance(cond.show_when, list) else cond.show_when
134
+ key = (cond.trigger, show_key)
135
+
136
+ if key not in conditions:
137
+ conditions[key] = []
138
+ conditions[key].append((field_name, cond.required_when_shown))
139
+ fields_to_remove.add(field_name)
140
+
141
+ return conditions, fields_to_remove
142
+
143
+
144
+ def _build_if_clause(trigger: str, *, show_when: bool | str | tuple[str, ...]) -> dict[str, Any]:
145
+ """Build the if clause for a conditional.
146
+
147
+ Args:
148
+ trigger: The trigger field name.
149
+ show_when: The value(s) that trigger visibility.
150
+
151
+ Returns:
152
+ The if clause dict.
153
+ """
154
+ if isinstance(show_when, tuple):
155
+ return {"properties": {trigger: {"enum": list(show_when)}}, "required": [trigger]}
156
+ return {"properties": {trigger: {"const": show_when}}, "required": [trigger]}
157
+
158
+
159
+ def _resolve_field_schema(
160
+ field_schema: dict[str, Any],
161
+ handler: GetJsonSchemaHandler,
162
+ ) -> dict[str, Any]:
163
+ """Resolve $ref in field schema if present.
164
+
165
+ Args:
166
+ field_schema: The field's schema dict.
167
+ handler: The JSON schema handler for resolving refs.
168
+
169
+ Returns:
170
+ The resolved schema dict.
171
+ """
172
+ if "$ref" not in field_schema:
173
+ return field_schema
174
+
175
+ resolved = handler.resolve_ref_schema(field_schema)
176
+ extra = {k: v for k, v in field_schema.items() if k != "$ref"}
177
+ return {**resolved, **extra}
178
+
179
+
180
+ class ConditionalSchemaMixin(BaseModel):
181
+ """Mixin for automatic conditional field processing in JSON schema.
182
+
183
+ Inherit from this mixin to automatically generate JSON Schema with
184
+ if/then clauses for fields marked with ConditionalField metadata.
185
+
186
+ The mixin processes Annotated fields with Conditional metadata and:
187
+ 1. Removes conditional fields from main properties
188
+ 2. Adds them to allOf with if/then clauses
189
+ 3. Groups multiple fields with the same condition together
190
+
191
+ Example:
192
+ class Config(ConditionalSchemaMixin, BaseModel):
193
+ mode: Literal["basic", "advanced"] = Field(...)
194
+
195
+ advanced_option: Annotated[
196
+ str,
197
+ Conditional(trigger="mode", show_when="advanced"),
198
+ ] = Field(...)
199
+
200
+ # Generates schema with:
201
+ # {
202
+ # "properties": {"mode": {...}},
203
+ # "allOf": [{
204
+ # "if": {"properties": {"mode": {"const": "advanced"}}},
205
+ # "then": {"properties": {"advanced_option": {...}}}
206
+ # }]
207
+ # }
208
+ """
209
+
210
+ model_fields: ClassVar[dict[str, FieldInfo]] # type: ignore[misc]
211
+
212
+ @classmethod
213
+ def __get_pydantic_json_schema__( # noqa: PLW3201
214
+ cls,
215
+ core_schema: CoreSchema,
216
+ handler: GetJsonSchemaHandler,
217
+ ) -> JsonSchemaValue:
218
+ """Generate JSON schema with conditional field handling.
219
+
220
+ Args:
221
+ core_schema: The Pydantic core schema.
222
+ handler: The JSON schema handler for resolving refs.
223
+
224
+ Returns:
225
+ The JSON schema with if/then clauses for conditional fields.
226
+ """
227
+ schema = handler(core_schema)
228
+ props = schema.get("properties", {})
229
+ if not props:
230
+ return schema
231
+
232
+ conditions, fields_to_remove = _collect_conditions(cls.model_fields, props)
233
+ if not conditions:
234
+ return schema
235
+
236
+ all_of = schema.setdefault("allOf", [])
237
+
238
+ for (trigger, show_when), field_list in conditions.items():
239
+ then_props: dict[str, Any] = {}
240
+ then_required: list[str] = []
241
+
242
+ for field_name, required in field_list:
243
+ then_props[field_name] = _resolve_field_schema(props[field_name], handler)
244
+ if required:
245
+ then_required.append(field_name)
246
+
247
+ if_clause = _build_if_clause(trigger, show_when=show_when)
248
+ then_clause: dict[str, Any] = {"properties": then_props}
249
+ if then_required:
250
+ then_clause["required"] = then_required
251
+
252
+ all_of.append({"if": if_clause, "then": then_clause})
253
+
254
+ for field_name in fields_to_remove:
255
+ del props[field_name]
256
+
257
+ if "required" in schema:
258
+ schema["required"] = [r for r in schema["required"] if r not in fields_to_remove]
259
+
260
+ return schema
@@ -81,6 +81,9 @@ class SchemaSplitter:
81
81
  json_target["properties"] = {}
82
82
  for prop_name, prop_value in value.items():
83
83
  if isinstance(prop_value, dict):
84
+ # Skip hidden fields
85
+ if prop_value.get("hidden") is True:
86
+ continue
84
87
  json_target["properties"][prop_name] = {}
85
88
  prop_ui: dict[str, Any] = {}
86
89
  cls._process_property(prop_value, json_target["properties"][prop_name], prop_ui, defs_ui)
@@ -118,6 +121,9 @@ class SchemaSplitter:
118
121
  cls._strip_ui_properties(value, json_target[key])
119
122
  # Extract UI properties from conditional
120
123
  cls._extract_ui_properties(value, ui_target)
124
+ elif key == "hidden":
125
+ # Strip hidden key from json schema
126
+ continue
121
127
  else:
122
128
  json_target[key] = value
123
129
 
@@ -151,6 +157,9 @@ class SchemaSplitter:
151
157
  json_target["properties"] = {}
152
158
  for prop_name, prop_value in value.items():
153
159
  if isinstance(prop_value, dict):
160
+ # Skip hidden fields
161
+ if prop_value.get("hidden") is True:
162
+ continue
154
163
  json_target["properties"][prop_name] = {}
155
164
  prop_ui: dict[str, Any] = {}
156
165
  cls._process_property(prop_value, json_target["properties"][prop_name], prop_ui, defs_ui)
@@ -164,6 +173,9 @@ class SchemaSplitter:
164
173
  cls._process_property(value, json_target["items"], items_ui, defs_ui)
165
174
  if items_ui:
166
175
  ui_target["items"] = items_ui
176
+ elif key == "hidden":
177
+ # Strip hidden key from json schema
178
+ continue
167
179
  else:
168
180
  json_target[key] = value
169
181
 
@@ -176,12 +188,15 @@ class SchemaSplitter:
176
188
  json_target: Target dict without ui:* properties.
177
189
  """
178
190
  for key, value in source.items():
179
- if key.startswith("ui:"):
191
+ if key.startswith("ui:") or key == "hidden":
180
192
  continue
181
193
  if key == "properties" and isinstance(value, dict):
182
194
  json_target["properties"] = {}
183
195
  for prop_name, prop_value in value.items():
184
196
  if isinstance(prop_value, dict):
197
+ # Skip hidden fields
198
+ if prop_value.get("hidden") is True:
199
+ continue
185
200
  json_target["properties"][prop_name] = {}
186
201
  cls._strip_ui_properties(prop_value, json_target["properties"][prop_name])
187
202
  else:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: digitalkin
3
- Version: 0.3.2.dev18
3
+ Version: 0.3.2.dev20
4
4
  Summary: SDK to build kin used in DigitalKin
5
5
  Author-email: "DigitalKin.ai" <contact@digitalkin.ai>
6
6
  License: Attribution-NonCommercial-ShareAlike 4.0 International
@@ -7,7 +7,7 @@ base_server/mock/__init__.py,sha256=YZFT-F1l_TpvJYuIPX-7kTeE1CfOjhx9YmNRXVoi-jQ,
7
7
  base_server/mock/mock_pb2.py,sha256=sETakcS3PAAm4E-hTCV1jIVaQTPEAIoVVHupB8Z_k7Y,1843
8
8
  base_server/mock/mock_pb2_grpc.py,sha256=BbOT70H6q3laKgkHfOx1QdfmCS_HxCY4wCOX84YAdG4,3180
9
9
  digitalkin/__init__.py,sha256=7LLBAba0th-3SGqcpqFO-lopWdUkVLKzLZiMtB-mW3M,162
10
- digitalkin/__version__.py,sha256=6eu1NkqoRgH4x9vy4KK1UNb25WQD1SomFTIF5afEldM,196
10
+ digitalkin/__version__.py,sha256=dN3fPmuqWC0wGuJWF7p4H8rHGbT_gGfHvNrei8I6TU8,196
11
11
  digitalkin/logger.py,sha256=8ze_tjt2G6mDTuQcsf7-UTXWP3UHZ7LZVSs_iqF4rX4,4685
12
12
  digitalkin/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
13
  digitalkin/core/__init__.py,sha256=FJRcJ-B1Viyn-38L8XpOpZ8KOnf1I7PCDOAmKXLQhqc,71
@@ -28,7 +28,7 @@ digitalkin/core/task_manager/task_session.py,sha256=5jw21bT_SPXUzWE7tk6YG62EXqlR
28
28
  digitalkin/grpc_servers/__init__.py,sha256=ZIRMJ1Lcas8yQ106GCup6hn2UBOsx1sNk8ap0lpEDnY,72
29
29
  digitalkin/grpc_servers/_base_server.py,sha256=ZVeCDwI7w7fFbPTXPkeJb_SOuLfd2T7za3T4oCu2UWY,18680
30
30
  digitalkin/grpc_servers/module_server.py,sha256=Ec3izzV2YpdN8rGs_cX-iVulQ00FkLR5dBflHlQ8a6Y,7849
31
- digitalkin/grpc_servers/module_servicer.py,sha256=7GQOyAPYMxHVaJGplgDNiVoKr1oaAIL-zdZpyDpznTA,20530
31
+ digitalkin/grpc_servers/module_servicer.py,sha256=KsWXPwCQ0s2pygn0cq89KndJleAkjvkvLFs1Z2fWQIk,21705
32
32
  digitalkin/grpc_servers/utils/__init__.py,sha256=ZnAIb_F8z4NhtPypqkdmzgRSzolKnJTk3oZx5GfWH5Y,38
33
33
  digitalkin/grpc_servers/utils/exceptions.py,sha256=LtaDtlqXCeT6iqApogs4pbtezotOVeg4fhnFzGBvFsY,692
34
34
  digitalkin/grpc_servers/utils/grpc_client_wrapper.py,sha256=nGG8QdKnBH0UG9qbKrlPwIvcvPgW3osw7O3cImxisPE,3279
@@ -55,7 +55,7 @@ digitalkin/models/module/base_types.py,sha256=oIylVNqo0idTFj4dRgCt7P19daNZ-AlvgC
55
55
  digitalkin/models/module/module.py,sha256=k0W8vfJJFth8XdDzkHm32SyTuSf3h2qF0hSrxAfGF1s,956
56
56
  digitalkin/models/module/module_context.py,sha256=QDdjZdhIJpvU_2Tn7kkJsZ1givB4dM1-ksopdF4VySw,12176
57
57
  digitalkin/models/module/module_types.py,sha256=C9azCNBk76xMa-Mww8_6AiwQR8MLAsEyUOvBYxytovI,739
58
- digitalkin/models/module/setup_types.py,sha256=namyC-0iA4ryHqKCzjYsVYeFDduwMqYCYRmQSSH8414,19356
58
+ digitalkin/models/module/setup_types.py,sha256=Xb_KZ5vKcpLqkbPdQfooqtI6TFp2cklvdXLUBa55c30,19346
59
59
  digitalkin/models/module/tool_cache.py,sha256=5e30A_GxT2W-w1LZFmVUqOxDjPcrZ8s_eW7p9impO64,7153
60
60
  digitalkin/models/module/tool_reference.py,sha256=eIWJrT6syyEaXAWRXIlWYTst-j0XuvtU_va9m3tj_KU,4470
61
61
  digitalkin/models/module/utility.py,sha256=gnbYfWpXGbomUI0fWf7T-Qm_VvT-LXDv1OuA9zObwVg,5589
@@ -64,7 +64,7 @@ digitalkin/models/services/cost.py,sha256=9PXvd5RrIk9vCrRjcUGQ9ZyAokEbwLg4s0RfnE
64
64
  digitalkin/models/services/registry.py,sha256=mFehnPAVLGimodHquNrltXbH_aE0jEa-PxfyNm6J38E,1828
65
65
  digitalkin/models/services/storage.py,sha256=wp7F-AvTsU46ujGPcguqM5kUKRZx4399D4EGAAJt2zs,1143
66
66
  digitalkin/modules/__init__.py,sha256=vTQk8DWopxQSJ17BjE5dNhq247Rou55iQLJdBxoPUmo,296
67
- digitalkin/modules/_base_module.py,sha256=vQX-vqGXafc9DkNJXzTic-1zJ3yrP1BC30fTH8yHB1k,22592
67
+ digitalkin/modules/_base_module.py,sha256=0XC0aQAxlNfvz0KK9ut7K0JbZql3cZMU4aeg7ISEsD0,21971
68
68
  digitalkin/modules/archetype_module.py,sha256=XC9tl1Yr6QlbPn_x0eov6UUZwQgwW--BYPPMYVJH_NU,505
69
69
  digitalkin/modules/tool_module.py,sha256=GBis7bKCkvWFCYLRvaS9oZVmLBBve1w8BhVnKOU2sCc,506
70
70
  digitalkin/modules/trigger_handler.py,sha256=qPNMi-8NHqscOxciHeaXtpwjXApT3YzjMF23zQAjaZY,1770
@@ -115,14 +115,15 @@ digitalkin/services/user_profile/__init__.py,sha256=RKEZCsgCHS7fmswhWgUoQd6vZ_1p
115
115
  digitalkin/services/user_profile/default_user_profile.py,sha256=46DH_VBCHKXJVyagVcc8kH5sLwRK54Fe_0ahqYJ1maA,1847
116
116
  digitalkin/services/user_profile/grpc_user_profile.py,sha256=xDiUC5Ceofa6QtGPmqJV3ik5j8HDHc1zxtpia49rlRw,2780
117
117
  digitalkin/services/user_profile/user_profile_strategy.py,sha256=CH8kT__1MUwA21k5djjmB5ZZ6pYg57OWbe_7owBCgwU,681
118
- digitalkin/utils/__init__.py,sha256=xusG_EoixTznt9ENCB8XkNbi67SZNt6y9hx5gsPWH_k,464
118
+ digitalkin/utils/__init__.py,sha256=_RXXALotIr_JToSQdhwDi3eizghKz9KrQtvC8Um80H8,808
119
119
  digitalkin/utils/arg_parser.py,sha256=wzscRlE1Qp1gGl-lAJlkkwnbU1O2oezj6BwK_BZFBIk,3158
120
+ digitalkin/utils/conditional_schema.py,sha256=ZyGvY-DWkM_d0OuLElUpqnvUeDgZ2hXX3dFnKXbgL5k,8373
120
121
  digitalkin/utils/development_mode_action.py,sha256=2hznh0ajW_4ZTysfoc0Y49161f_PQPATRgNk8NAn1_o,1623
121
122
  digitalkin/utils/dynamic_schema.py,sha256=y5csxjuqVHjWDpnTUzxbcUuI_wou9-ibRVHQlBs_btY,15275
122
123
  digitalkin/utils/llm_ready_schema.py,sha256=JjMug_lrQllqFoanaC091VgOqwAd-_YzcpqFlS7p778,2375
123
124
  digitalkin/utils/package_discover.py,sha256=sa6Zp5Kape1Zr4iYiNrnZxiHDnqM06ODk6yfWHom53w,13465
124
- digitalkin/utils/schema_splitter.py,sha256=9PHC-bvEDQudyYZNgXyjFtp7EJlmw4C_gPCJ-JmGDk0,9704
125
- digitalkin-0.3.2.dev18.dist-info/licenses/LICENSE,sha256=Ies4HFv2r2hzDRakJYxk3Y60uDFLiG-orIgeTpstnIo,20327
125
+ digitalkin/utils/schema_splitter.py,sha256=7KFiRUhFE9HsH_Z8p0POSbIhiJlgZEeSxL_7TDY0n1U,10374
126
+ digitalkin-0.3.2.dev20.dist-info/licenses/LICENSE,sha256=Ies4HFv2r2hzDRakJYxk3Y60uDFLiG-orIgeTpstnIo,20327
126
127
  modules/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
127
128
  modules/archetype_with_tools_module.py,sha256=PXTS6IXmC_OjxTmVrL_pYVI0MKwXjD5I1UJO_2xa10Q,7632
128
129
  modules/cpu_intensive_module.py,sha256=GZlirQDZdYuXrI46sv1q4RNAHZjL4EptHVQTvgK9zz8,8363
@@ -137,7 +138,7 @@ monitoring/digitalkin_observability/prometheus.py,sha256=gDmM9ySaVwPAe7Yg84pLxmE
137
138
  monitoring/tests/test_metrics.py,sha256=ugnYfAwqBPO6zA8z4afKTlyBWECTivacYSN-URQCn2E,5856
138
139
  services/filesystem_module.py,sha256=U4dgqtuDadaXz8PJ1d_uQ_1EPncBqudAQCLUICF9yL4,7421
139
140
  services/storage_module.py,sha256=Wz2MzLvqs2D_bnBBgtnujYcAKK2V2KFMk8K21RoepSE,6972
140
- digitalkin-0.3.2.dev18.dist-info/METADATA,sha256=sJPrEQfDsIC_mcsZfFttiA3fybX3J7KbVT7zozw3qMw,29725
141
- digitalkin-0.3.2.dev18.dist-info/WHEEL,sha256=qELbo2s1Yzl39ZmrAibXA2jjPLUYfnVhUNTlyF1rq0Y,92
142
- digitalkin-0.3.2.dev18.dist-info/top_level.txt,sha256=AYVIesKrO0jnedQ-Muog9JBehG81WeTCNeOFoJgwsgE,51
143
- digitalkin-0.3.2.dev18.dist-info/RECORD,,
141
+ digitalkin-0.3.2.dev20.dist-info/METADATA,sha256=EosqD8N82EhYPNyjWKp-_n2az8875Qp8iRfdZph5uB4,29725
142
+ digitalkin-0.3.2.dev20.dist-info/WHEEL,sha256=qELbo2s1Yzl39ZmrAibXA2jjPLUYfnVhUNTlyF1rq0Y,92
143
+ digitalkin-0.3.2.dev20.dist-info/top_level.txt,sha256=AYVIesKrO0jnedQ-Muog9JBehG81WeTCNeOFoJgwsgE,51
144
+ digitalkin-0.3.2.dev20.dist-info/RECORD,,