ob-metaflow-extensions 1.1.175rc0__py2.py3-none-any.whl → 1.1.175rc2__py2.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.

Potentially problematic release.


This version of ob-metaflow-extensions might be problematic. Click here for more details.

Files changed (18) hide show
  1. metaflow_extensions/outerbounds/plugins/apps/core/app_cli.py +44 -376
  2. metaflow_extensions/outerbounds/plugins/apps/core/app_config.py +45 -225
  3. metaflow_extensions/outerbounds/plugins/apps/core/code_package/code_packager.py +16 -15
  4. metaflow_extensions/outerbounds/plugins/apps/core/config/__init__.py +11 -0
  5. metaflow_extensions/outerbounds/plugins/apps/core/config/cli_generator.py +161 -0
  6. metaflow_extensions/outerbounds/plugins/apps/core/config/config_utils.py +768 -0
  7. metaflow_extensions/outerbounds/plugins/apps/core/config/schema_export.py +285 -0
  8. metaflow_extensions/outerbounds/plugins/apps/core/config/unified_config.py +870 -0
  9. metaflow_extensions/outerbounds/plugins/apps/core/config_schema.yaml +217 -211
  10. metaflow_extensions/outerbounds/plugins/apps/core/dependencies.py +3 -3
  11. metaflow_extensions/outerbounds/plugins/apps/core/experimental/__init__.py +4 -36
  12. metaflow_extensions/outerbounds/plugins/apps/core/validations.py +4 -9
  13. {ob_metaflow_extensions-1.1.175rc0.dist-info → ob_metaflow_extensions-1.1.175rc2.dist-info}/METADATA +1 -1
  14. {ob_metaflow_extensions-1.1.175rc0.dist-info → ob_metaflow_extensions-1.1.175rc2.dist-info}/RECORD +16 -13
  15. metaflow_extensions/outerbounds/plugins/apps/core/cli_to_config.py +0 -99
  16. metaflow_extensions/outerbounds/plugins/apps/core/config_schema_autogen.json +0 -336
  17. {ob_metaflow_extensions-1.1.175rc0.dist-info → ob_metaflow_extensions-1.1.175rc2.dist-info}/WHEEL +0 -0
  18. {ob_metaflow_extensions-1.1.175rc0.dist-info → ob_metaflow_extensions-1.1.175rc2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,768 @@
1
+ import os
2
+ import json
3
+ from typing import Any, Dict, List, Optional, Union, Type, Callable
4
+ from ..click_importer import click
5
+
6
+
7
+ class FieldBehavior:
8
+ """
9
+ Defines how configuration fields behave when merging values from multiple sources.
10
+
11
+ FieldBehavior controls the merging logic when the same field receives values from
12
+ different configuration sources (CLI options, config files, environment variables).
13
+ This is crucial for maintaining consistent and predictable configuration behavior
14
+ across different deployment scenarios.
15
+
16
+ The behavior system allows fine-grained control over how different types of fields
17
+ should handle conflicting values, ensuring that sensitive configuration (like
18
+ dependency specifications) cannot be accidentally overridden while still allowing
19
+ flexible configuration for runtime parameters.
20
+
21
+ Behavior Types:
22
+
23
+ UNION (Default):
24
+ - **For Primitive Types**: Override value takes precedence
25
+ - **For Lists**: Values are merged by extending the base list with override values
26
+ - **For Dictionaries**: Values are merged by updating base dict with override values
27
+ - **For Nested Objects**: Recursively merge nested configuration objects
28
+
29
+ Example:
30
+ ```python
31
+ # Base config: {"tags": ["prod", "web"]}
32
+ # CLI config: {"tags": ["urgent"]}
33
+ # Result: {"tags": ["prod", "web", "urgent"]}
34
+ ```
35
+
36
+ NOT_ALLOWED:
37
+ - CLI values cannot override config file values
38
+ - CLI values are only used if config file value is None
39
+ - Ensures critical configuration is only set in one place to avoid ambiguity.
40
+
41
+ Example:
42
+ ```python
43
+ # Base config: {"dependencies": {"numpy": "1.21.0"}}
44
+ # CLI config: {"dependencies": {"numpy": "1.22.0"}}
45
+ # Result: Exception is raised
46
+ ```
47
+
48
+ ```python
49
+ # Base config: {"dependencies": {"pypi": null, "conda": null}}
50
+ # CLI config: {"dependencies": {"pypi": {"numpy": "1.22.0"}}}
51
+ # Result: {"dependencies": {"pypi": {"numpy": "1.22.0"}}} # since there is nothing in base config, the CLI config is used.
52
+ ```
53
+
54
+ Integration with Merging:
55
+ The behavior is enforced by the `merge_field_values` function during configuration
56
+ merging. Each field's behavior is checked and the appropriate merging logic is applied.
57
+ """
58
+
59
+ UNION = "union" # CLI values are merged with config file values
60
+ # CLI values are not allowed to ovveride the config values
61
+ # unless config values are not specified
62
+ NOT_ALLOWED = "not_allowed"
63
+
64
+
65
+ class CLIOption:
66
+ """Metadata container for automatic CLI option generation from configuration fields.
67
+
68
+ CLIOption defines how a ConfigField should be exposed as a command-line option in the
69
+ generated CLI interface. It provides a declarative way to specify CLI parameter names,
70
+ help text, validation rules, and Click-specific behaviors without tightly coupling
71
+ configuration definitions to CLI implementation details.
72
+
73
+ This class bridges the gap between configuration field definitions and Click option
74
+ generation, allowing the same field definition to work seamlessly across different
75
+ interfaces (CLI, config files, programmatic usage).
76
+
77
+ Click Integration:
78
+ The CLIOption metadata is used by CLIGenerator to create Click options:
79
+ ```python
80
+ @click.option("--port", "port", type=int, help="Application port")
81
+ ```
82
+
83
+ This is automatically generated from:
84
+ ```python
85
+ port = ConfigField(
86
+ cli_meta=CLIOption(
87
+ name="port",
88
+ cli_option_str="--port",
89
+ help="Application port"
90
+ ),
91
+ field_type=int
92
+ )
93
+ ```
94
+
95
+
96
+ Parameters
97
+ ----------
98
+ name : str
99
+ Parameter name used in Click option and function signature (e.g., "my_foo").
100
+ cli_option_str : str
101
+ Command-line option string (e.g., "--foo", "--enable/--disable").
102
+ help : Optional[str], optional
103
+ Help text displayed in CLI help output.
104
+ short : Optional[str], optional
105
+ Short option character (e.g., "-f" for "--foo").
106
+ multiple : bool, optional
107
+ Whether the option accepts multiple values.
108
+ is_flag : bool, optional
109
+ Whether this is a boolean flag option.
110
+ choices : Optional[List[str]], optional
111
+ List of valid choices for the option.
112
+ default : Any, optional
113
+ Default value for the CLI option (separate from ConfigField default).
114
+ hidden : bool, optional
115
+ Whether to hide this option from CLI (config file only).
116
+ click_type : Optional[Any], optional
117
+ Custom Click type for specialized parsing (e.g., KeyValuePair).
118
+ """
119
+
120
+ def __init__(
121
+ self,
122
+ name: str, # This name corresponds to the `"my_foo"` in `@click.option("--foo","my_foo")`
123
+ cli_option_str: str, # This corresponds to the `--foo` in the `@click.option("--foo","my_foo")`
124
+ help: Optional[str] = None,
125
+ short: Optional[str] = None,
126
+ multiple: bool = False,
127
+ is_flag: bool = False,
128
+ choices: Optional[List[str]] = None,
129
+ default: Any = None,
130
+ hidden: bool = False,
131
+ click_type: Optional[Any] = None,
132
+ ):
133
+ self.name = name
134
+ self.cli_option_str = cli_option_str
135
+ self.help = help
136
+ self.short = short
137
+ self.multiple = multiple
138
+ self.is_flag = is_flag
139
+ self.choices = choices
140
+ self.default = default
141
+ self.hidden = hidden
142
+ self.click_type = click_type
143
+
144
+
145
+ class ConfigField:
146
+ """Descriptor for configuration fields with comprehensive metadata and behavior control.
147
+
148
+ ConfigField is a Python descriptor that provides a declarative way to define configuration
149
+ fields with rich metadata, validation, CLI integration, and merging behavior. It acts as
150
+ both a data descriptor (controlling get/set access) and a metadata container.
151
+
152
+ Key Functionality:
153
+ - **Descriptor Protocol**: Implements __get__, __set__, and __set_name__ to control
154
+ field access and automatically capture the field name during class creation.
155
+ - **Type Safety**: Optional strict type checking during value assignment.
156
+ - **CLI Integration**: Automatic CLI option generation via CLIOption metadata.
157
+ - **Validation**: Built-in validation functions and required field checks.
158
+ - **Merging Behavior**: Controls how values are merged from different sources (CLI, config files).
159
+ - **Default Values**: Supports both static defaults and callable defaults for dynamic initialization.
160
+
161
+ Merging Behaviors:
162
+ - **UNION**: Values from different sources are merged (lists extended, dicts updated).
163
+ - **NOT_ALLOWED**: Override values are ignored if base value exists.
164
+
165
+ Field Lifecycle:
166
+ 1. **Definition**: Field is defined in a ConfigMeta-based class
167
+ 2. **Registration**: __set_name__ is called to register the field name
168
+ 3. **Initialization**: Field is initialized with None or nested config objects
169
+ 4. **Population**: Values are set from CLI options, config files, or direct assignment
170
+ 5. **Validation**: Validation functions are called during commit phase
171
+ 6. **Default Application**: Default values are applied to None fields
172
+
173
+ Examples:
174
+ Basic field definition:
175
+ ```python
176
+ name = ConfigField(
177
+ field_type=str,
178
+ required=True,
179
+ help="Application name",
180
+ example="myapp"
181
+ )
182
+ ```
183
+
184
+ Field with CLI integration:
185
+ ```python
186
+ port = ConfigField(
187
+ cli_meta=CLIOption(
188
+ name="port",
189
+ cli_option_str="--port",
190
+ help="Application port"
191
+ ),
192
+ field_type=int,
193
+ required=True,
194
+ validation_fn=lambda x: 1 <= x <= 65535
195
+ )
196
+ ```
197
+
198
+ Nested configuration field:
199
+ ```python
200
+ resources = ConfigField(
201
+ field_type=ResourceConfig,
202
+ help="Resource configuration"
203
+ )
204
+ ```
205
+
206
+ Parameters
207
+ ----------
208
+ default : Any or Callable[["ConfigField"], Any], optional
209
+ Default value for the field. Can be a static value or a callable for dynamic defaults.
210
+ cli_meta : CLIOption, optional
211
+ CLIOption instance defining CLI option generation parameters.
212
+ field_type : type, optional
213
+ Expected type of the field value (used for validation and nesting).
214
+ required : bool, optional
215
+ Whether the field must have a non-None value after configuration.
216
+ help : str, optional
217
+ Help text describing the field's purpose.
218
+ behavior : str, optional
219
+ FieldBehavior controlling how values are merged from different sources.
220
+ example : Any, optional
221
+ Example value for documentation and schema generation.
222
+ strict_types : bool, optional
223
+ Whether to enforce type checking during value assignment.
224
+ validation_fn : callable, optional
225
+ Optional function to validate field values.
226
+ is_experimental : bool, optional
227
+ Whether this field is experimental (for documentation).
228
+ """
229
+
230
+ def __init__(
231
+ self,
232
+ default: Union[Any, Callable[["ConfigField"], Any]] = None,
233
+ cli_meta=None,
234
+ field_type=None,
235
+ required=False,
236
+ help=None,
237
+ behavior: str = FieldBehavior.UNION,
238
+ example=None,
239
+ strict_types=True,
240
+ validation_fn: Optional[callable] = None,
241
+ is_experimental=False, # This property is for bookkeeping purposes and for export in schema.
242
+ ):
243
+ if behavior == FieldBehavior.NOT_ALLOWED and ConfigMeta.is_instance(field_type):
244
+ raise ValueError(
245
+ "NOT_ALLOWED behavior cannot be set for ConfigMeta-based objects."
246
+ )
247
+ if callable(default) and not ConfigMeta.is_instance(field_type):
248
+ raise ValueError(
249
+ f"Default value for {field_type} is a callable but it is not a ConfigMeta-based object."
250
+ )
251
+
252
+ self.default = default
253
+ self.cli_meta = cli_meta
254
+ self.field_type = field_type
255
+ self.strict_types = strict_types
256
+ # Strict types means that the __set__ will raise an error if the type of the valu
257
+ # doesn't match the type of the field.
258
+ self.required = required
259
+ self.help = help
260
+ self.behavior = behavior
261
+ self.example = example
262
+ self.name = None
263
+ self.validation_fn = validation_fn
264
+ self.is_experimental = is_experimental
265
+
266
+ def __set_name__(self, owner, name):
267
+ self.name = name
268
+
269
+ def __get__(self, instance, owner):
270
+ if instance is None:
271
+ return self
272
+ # DEFAULTS need to be explicilty set to ensure that
273
+ # only explicitly set values are return on get
274
+ return instance.__dict__.get(self.name)
275
+
276
+ def __set__(self, instance, value):
277
+ # TODO: handle this execption at top level if necessary.
278
+ if value is not None and self.strict_types:
279
+ if not isinstance(value, self.field_type):
280
+ raise ValueError(f"Value {value} is not of type {self.field_type}")
281
+ instance.__dict__[self.name] = value
282
+
283
+ def __str__(self) -> str:
284
+ type_name = (
285
+ getattr(self.field_type, "__name__", "typing.Any")
286
+ if self.field_type
287
+ else "typing.Any"
288
+ )
289
+ return f"<ConfigField name='{self.name}' type={type_name} default={self.default!r}>"
290
+
291
+
292
+ class ConfigMeta(type):
293
+ """Metaclass that transforms regular classes into configuration classes with automatic field management.
294
+
295
+ ConfigMeta is the core metaclass that enables the declarative configuration system. It automatically
296
+ processes ConfigField descriptors defined in class bodies and transforms them into fully functional
297
+ configuration classes with standardized initialization, field access, and metadata management.
298
+
299
+ Key Transformations:
300
+ - **Field Collection**: Automatically discovers and collects all ConfigField instances from the class body.
301
+ - **Metadata Storage**: Stores field metadata in a `_fields` class attribute for runtime introspection.
302
+ - **Auto-Generated __init__**: Creates a standardized __init__ method that handles field initialization.
303
+ - **Field Access**: Injects helper methods like `_get_field` for programmatic field access.
304
+ - **Nested Object Support**: Automatically instantiates nested configuration objects during initialization.
305
+
306
+ Class Transformation Process:
307
+ 1. **Discovery**: Scan the class namespace for ConfigField instances
308
+ 2. **Registration**: Store found fields in `_fields` dictionary
309
+ 3. **Method Injection**: Add `_get_field` helper method to the class
310
+ 4. **__init__ Generation**: Create standardized initialization logic
311
+ 5. **Class Creation**: Return the transformed class with all enhancements
312
+
313
+ Generated __init__ Behavior:
314
+ - Initializes all fields to None by default (explicit defaulting is done separately)
315
+ - Automatically creates instances of nested ConfigMeta-based classes
316
+ - Accepts keyword arguments to override field values during instantiation
317
+ - Ensures consistent initialization patterns across all configuration classes
318
+
319
+ Usage Pattern:
320
+ ```python
321
+ class MyConfig(metaclass=ConfigMeta):
322
+ name = ConfigField(field_type=str, required=True)
323
+ port = ConfigField(field_type=int, default=8080)
324
+ resources = ConfigField(field_type=ResourceConfig)
325
+
326
+ # The metaclass transforms this into a fully functional config class
327
+ config = MyConfig() # Uses auto-generated __init__
328
+ config.name = "myapp" # Uses ConfigField descriptor
329
+ field_info = config._get_field("name") # Uses injected helper method
330
+ ```
331
+
332
+ Integration Points:
333
+ - **CLI Generation**: Field metadata is used to automatically generate CLI options
334
+ - **Config Loading**: Fields are populated from dictionaries, YAML, or JSON files
335
+ - **Validation**: Field validation functions are called during config commit
336
+ - **Merging**: Field behaviors control how values are merged from different sources
337
+ - **Export**: Configuration instances can be exported back to dictionaries
338
+
339
+ The metaclass ensures that all configuration classes have consistent behavior and
340
+ interfaces, regardless of their specific field definitions.
341
+ """
342
+
343
+ @staticmethod
344
+ def is_instance(value) -> bool:
345
+ return hasattr(value, "_fields")
346
+
347
+ def __new__(mcs, name, bases, namespace):
348
+ # Collect field metadata
349
+ fields = {}
350
+ for key, value in namespace.items():
351
+ if isinstance(value, ConfigField):
352
+ fields[key] = value
353
+
354
+ # Store fields metadata on the class
355
+ namespace["_fields"] = fields
356
+
357
+ # Inject a function to get configField from a field name
358
+ def get_field(cls, field_name: str) -> ConfigField:
359
+ return fields[field_name]
360
+
361
+ namespace["_get_field"] = get_field
362
+
363
+ # Auto-generate __init__ method;
364
+ # Override it for all classes.
365
+ def __init__(self, **kwargs):
366
+ # Initialize all fields with Nones or other values
367
+ # We initiaze with None because defaulting should be a
368
+ # constant behavior
369
+ for field_name, field_info in fields.items():
370
+ default_value = None
371
+
372
+ # Handle nested config objects
373
+ if ConfigMeta.is_instance(field_info.field_type):
374
+ # Create nested config instance with defaults
375
+ default_value = field_info.field_type()
376
+
377
+ # Set from kwargs or use default
378
+ if field_name in kwargs:
379
+ setattr(self, field_name, kwargs[field_name])
380
+ else:
381
+ setattr(self, field_name, default_value)
382
+
383
+ return super().__new__(mcs, name, bases, namespace)
384
+
385
+
386
+ def apply_defaults(config) -> None:
387
+ """
388
+ Apply default values to any fields that are still None.
389
+
390
+ Args:
391
+ config: instance of a ConfigMeta object
392
+ """
393
+ for field_name, field_info in config._fields.items():
394
+ current_value = getattr(config, field_name, None)
395
+
396
+ if current_value is None:
397
+ # The nested configs will never be set to None
398
+ # Since we always override the init function.
399
+ # The init function will always instantiate the
400
+ # sub-objects under the class
401
+ # Set default value for regular fields
402
+ setattr(config, field_name, field_info.default)
403
+ elif ConfigMeta.is_instance(field_info.field_type) and ConfigMeta.is_instance(
404
+ current_value
405
+ ):
406
+
407
+ # Apply defaults to nested config (to any sub values that might need it)
408
+ apply_defaults(current_value)
409
+ # also apply defaults to the current value if a defaults callable is provided.
410
+ # Certain top level config fields might require default setting based on the
411
+ # what the current value in the fields is set.
412
+ if field_info.default:
413
+ field_info.default(current_value)
414
+
415
+
416
+ class ConfigValidationFailedException(Exception):
417
+ def __init__(
418
+ self,
419
+ field_name: str,
420
+ field_info: ConfigField,
421
+ current_value,
422
+ message: str = None,
423
+ ):
424
+ self.field_name = field_name
425
+ self.field_info = field_info
426
+ self.current_value = current_value
427
+ self.message = (
428
+ f"Validation failed for field {field_name} with value {current_value}"
429
+ )
430
+ if message is not None:
431
+ self.message = message
432
+
433
+ super().__init__(self.message)
434
+
435
+
436
+ class RequiredFieldMissingException(ConfigValidationFailedException):
437
+ pass
438
+
439
+
440
+ class MergingNotAllowedFieldsException(ConfigValidationFailedException):
441
+ def __init__(
442
+ self,
443
+ field_name: str,
444
+ field_info: ConfigField,
445
+ current_value: Any,
446
+ override_value: Any,
447
+ ):
448
+ super().__init__(
449
+ field_name=field_name,
450
+ field_info=field_info,
451
+ current_value=current_value,
452
+ message=f"Merging not allowed for field {field_name} with value {current_value} and override value {override_value}",
453
+ )
454
+ self.override_value = override_value
455
+
456
+
457
+ def validate_required_fields(config_instance):
458
+ for field_name, field_info in config_instance._fields.items():
459
+ if field_info.required:
460
+ current_value = getattr(config_instance, field_name, None)
461
+ if current_value is None:
462
+ raise RequiredFieldMissingException(
463
+ field_name, field_info, current_value
464
+ )
465
+ if ConfigMeta.is_instance(field_info.field_type) and ConfigMeta.is_instance(
466
+ current_value
467
+ ):
468
+ validate_required_fields(current_value)
469
+ # TODO : Fix the exception handling over here.
470
+
471
+
472
+ def validate_config_meta(config_instance):
473
+ for field_name, field_info in config_instance._fields.items():
474
+ current_value = getattr(config_instance, field_name, None)
475
+
476
+ if ConfigMeta.is_instance(field_info.field_type) and ConfigMeta.is_instance(
477
+ current_value
478
+ ):
479
+ validate_config_meta(current_value)
480
+
481
+ if field_info.validation_fn:
482
+ if not field_info.validation_fn(current_value):
483
+ raise ConfigValidationFailedException(
484
+ field_name, field_info, current_value
485
+ )
486
+
487
+
488
+ def config_meta_to_dict(config_instance) -> Dict[str, Any]:
489
+ """Convert a configuration instance to a nested dictionary.
490
+
491
+ Recursively converts ConfigMeta-based configuration instances to dictionaries,
492
+ handling nested config objects and preserving the structure.
493
+
494
+ Args:
495
+ config_instance: Instance of a ConfigMeta-based configuration class
496
+
497
+ Returns:
498
+ Nested dictionary representation of the configuration
499
+
500
+ Examples:
501
+ # Convert a config instance to dict
502
+
503
+ config_dict = to_dict(config)
504
+
505
+ # Result will be:
506
+ # {
507
+ # "name": "myapp",
508
+ # "port": 8000,
509
+ # "resources": {
510
+ # "cpu": "500m",
511
+ # "memory": "1Gi",
512
+ # "gpu": None,
513
+ # "disk": "20Gi"
514
+ # },
515
+ # "auth": None,
516
+ # ...
517
+ # }
518
+ """
519
+ if config_instance is None:
520
+ return None
521
+
522
+ # Check if this is a ConfigMeta-based class
523
+ if not ConfigMeta.is_instance(config_instance):
524
+ # If it's not a config object, return as-is
525
+ return config_instance
526
+
527
+ result = {}
528
+
529
+ # Iterate through all fields defined in the class
530
+ for field_name, field_info in config_instance._fields.items():
531
+ # Get the current value
532
+ value = getattr(config_instance, field_name, None)
533
+
534
+ # Handle nested config objects recursively
535
+ if value is not None and ConfigMeta.is_instance(value):
536
+ # It's a nested config object
537
+ result[field_name] = config_meta_to_dict(value)
538
+ elif isinstance(value, list) and value:
539
+ # Handle lists that might contain config objects
540
+ result[field_name] = [
541
+ config_meta_to_dict(item) if ConfigMeta.is_instance(item) else item
542
+ for item in value
543
+ ]
544
+ elif isinstance(value, dict) and value:
545
+ # Handle dictionaries that might contain config objects
546
+ result[field_name] = {
547
+ k: config_meta_to_dict(v) if ConfigMeta.is_instance(v) else v
548
+ for k, v in value.items()
549
+ }
550
+ else:
551
+ # Primitive type or None
552
+ result[field_name] = value
553
+
554
+ return result
555
+
556
+
557
+ def merge_field_values(
558
+ base_value: Any, override_value: Any, field_info, behavior: str
559
+ ) -> Any:
560
+ """
561
+ Merge individual field values based on behavior.
562
+
563
+ Args:
564
+ base_value: Value from base config
565
+ override_value: Value from override config
566
+ field_info: Field metadata
567
+ behavior: FieldBehavior for this field
568
+
569
+ Returns:
570
+ Merged value
571
+ """
572
+ # Handle NOT_ALLOWED behavior
573
+ if behavior == FieldBehavior.NOT_ALLOWED:
574
+ if base_value is not None and override_value is not None:
575
+ raise MergingNotAllowedFieldsException(
576
+ field_name=field_info.name,
577
+ field_info=field_info,
578
+ current_value=base_value,
579
+ override_value=override_value,
580
+ )
581
+ if base_value is None:
582
+ return override_value
583
+ return base_value
584
+
585
+ # Handle UNION behavior (default)
586
+ if behavior == FieldBehavior.UNION:
587
+ # If override is None, use base value
588
+ if override_value is None:
589
+ return (
590
+ base_value if base_value is not None else None
591
+ ) # We will not set defaults!
592
+
593
+ # If base is None, use override value
594
+ if base_value is None:
595
+ return override_value
596
+
597
+ # Handle nested config objects
598
+ if ConfigMeta.is_instance(field_info.field_type):
599
+ if isinstance(base_value, field_info.field_type) and isinstance(
600
+ override_value, field_info.field_type
601
+ ):
602
+ # Merge nested configs recursively
603
+ merged_nested = field_info.field_type()
604
+ for (
605
+ nested_field_name,
606
+ nested_field_info,
607
+ ) in field_info.field_type._fields.items():
608
+ nested_base = getattr(base_value, nested_field_name, None)
609
+ nested_override = getattr(override_value, nested_field_name, None)
610
+ nested_behavior = getattr(
611
+ nested_field_info, "behavior", FieldBehavior.UNION
612
+ )
613
+
614
+ merged_nested_value = merge_field_values(
615
+ nested_base, nested_override, nested_field_info, nested_behavior
616
+ )
617
+ setattr(merged_nested, nested_field_name, merged_nested_value)
618
+
619
+ return merged_nested
620
+ else:
621
+ # One is not a config object, use override
622
+ return override_value
623
+
624
+ # Handle lists (union behavior merges lists)
625
+ if isinstance(base_value, list) and isinstance(override_value, list):
626
+ # Merge lists by extending
627
+ merged_list = base_value.copy()
628
+ merged_list.extend(override_value)
629
+ return merged_list
630
+
631
+ # Handle dicts (union behavior merges dicts)
632
+ if isinstance(base_value, dict) and isinstance(override_value, dict):
633
+ merged_dict = base_value.copy()
634
+ merged_dict.update(override_value)
635
+ return merged_dict
636
+
637
+ # For other types, override takes precedence
638
+ return override_value
639
+
640
+ # Default: override takes precedence
641
+ return (
642
+ override_value
643
+ if override_value is not None
644
+ else (base_value if base_value is not None else None)
645
+ )
646
+
647
+
648
+ class JsonFriendlyKeyValuePair(click.ParamType):
649
+ name = "KV-PAIR" # type: ignore
650
+
651
+ def convert(self, value, param, ctx):
652
+ # Parse a string of the form KEY=VALUE into a dict {KEY: VALUE}
653
+ if len(value.split("=", 1)) != 2:
654
+ self.fail(
655
+ f"Invalid format for {value}. Expected format: KEY=VALUE", param, ctx
656
+ )
657
+
658
+ key, _value = value.split("=", 1)
659
+ try:
660
+ return {key: json.loads(_value)}
661
+ except json.JSONDecodeError:
662
+ return {key: _value}
663
+ except Exception as e:
664
+ self.fail(f"Invalid value for {value}. Error: {e}", param, ctx)
665
+
666
+ def __str__(self):
667
+ return repr(self)
668
+
669
+ def __repr__(self):
670
+ return "KV-PAIR"
671
+
672
+
673
+ class CommaSeparatedList(click.ParamType):
674
+ name = "COMMA-SEPARATED-LIST" # type: ignore
675
+
676
+ def convert(self, value, param, ctx):
677
+ if isinstance(value, list):
678
+ return value
679
+ if isinstance(value, str):
680
+ return [item.strip() for item in value.split(",") if item.strip()]
681
+ return value
682
+
683
+ def __str__(self):
684
+ return repr(self)
685
+
686
+ def __repr__(self):
687
+ return "COMMA-SEPARATED-LIST"
688
+
689
+
690
+ class PureStringKVPair(click.ParamType):
691
+ """Click type for key-value pairs (KEY=VALUE)."""
692
+
693
+ name = "key=value"
694
+
695
+ def convert(self, value, param, ctx):
696
+ if isinstance(value, dict):
697
+ return value
698
+ try:
699
+ key, val = value.split("=", 1)
700
+ return {key: val}
701
+ except ValueError:
702
+ self.fail(f"'{value}' is not a valid key=value pair", param, ctx)
703
+
704
+
705
+ PureStringKVPairType = PureStringKVPair()
706
+ CommaSeparatedListType = CommaSeparatedList()
707
+ JsonFriendlyKeyValuePairType = JsonFriendlyKeyValuePair()
708
+
709
+
710
+ def populate_config_recursive(
711
+ config_instance,
712
+ config_class,
713
+ source_data,
714
+ get_source_key_fn,
715
+ get_source_value_fn,
716
+ ):
717
+ """
718
+ Recursively populate a config instance from source data.
719
+
720
+ Args:
721
+ config_instance: Config object to populate
722
+ config_class: Class of the config object
723
+ source_data: Source data (dict, CLI options, etc.)
724
+ get_source_key_fn: Function to get the source key for a field
725
+ get_source_value_fn: Function to get the value from source for a key
726
+ """
727
+
728
+ for field_name, field_info in config_class._fields.items():
729
+ # When we populate the ConfigMeta based objects, we want to do the following:
730
+ # If we find some key associated to the object inside the source data, then we populate the object
731
+ # with it. If that key corresponds to a nested config meta object then we just recusively pass down the
732
+ # value of the key from source data and populate the nested object other wise just end up setting the object
733
+ source_key = get_source_key_fn(field_name, field_info)
734
+ if source_key and source_key in source_data:
735
+ value = get_source_value_fn(source_data, source_key)
736
+ if value is not None:
737
+ # Handle nested config objects (for dict sources with nested data)
738
+ if ConfigMeta.is_instance(field_info.field_type) and isinstance(
739
+ value, dict
740
+ ):
741
+ nested_config = field_info.field_type()
742
+ populate_config_recursive(
743
+ nested_config,
744
+ field_info.field_type,
745
+ value, # For dict, use the nested dict as source
746
+ get_source_key_fn,
747
+ get_source_value_fn,
748
+ )
749
+ setattr(config_instance, field_name, nested_config)
750
+ else:
751
+ # Direct value assignment for regular fields
752
+ setattr(config_instance, field_name, value)
753
+ elif ConfigMeta.is_instance(field_info.field_type):
754
+ # It might be possible that the source key that respresents a subfield is
755
+ # is present at the root level but there is no field pertainig to the ConfigMeta
756
+ # itself. So always recurisvely check the subtree and no matter what keep instantiating
757
+ # any nested config objects.
758
+ _nested_config = field_info.field_type()
759
+ populate_config_recursive(
760
+ _nested_config,
761
+ field_info.field_type,
762
+ source_data,
763
+ get_source_key_fn,
764
+ get_source_value_fn,
765
+ )
766
+ setattr(config_instance, field_name, _nested_config)
767
+ else:
768
+ pass