ob-metaflow-extensions 1.1.175rc1__py2.py3-none-any.whl → 1.1.175rc3__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 (28) hide show
  1. metaflow_extensions/outerbounds/plugins/__init__.py +1 -1
  2. metaflow_extensions/outerbounds/plugins/apps/app_deploy_decorator.py +112 -0
  3. metaflow_extensions/outerbounds/plugins/apps/core/__init__.py +9 -0
  4. metaflow_extensions/outerbounds/plugins/apps/core/app_cli.py +42 -374
  5. metaflow_extensions/outerbounds/plugins/apps/core/app_config.py +45 -225
  6. metaflow_extensions/outerbounds/plugins/apps/core/capsule.py +22 -6
  7. metaflow_extensions/outerbounds/plugins/apps/core/code_package/code_packager.py +16 -15
  8. metaflow_extensions/outerbounds/plugins/apps/core/config/__init__.py +12 -0
  9. metaflow_extensions/outerbounds/plugins/apps/core/config/cli_generator.py +161 -0
  10. metaflow_extensions/outerbounds/plugins/apps/core/config/config_utils.py +828 -0
  11. metaflow_extensions/outerbounds/plugins/apps/core/config/schema_export.py +285 -0
  12. metaflow_extensions/outerbounds/plugins/apps/core/config/typed_configs.py +104 -0
  13. metaflow_extensions/outerbounds/plugins/apps/core/config/typed_init_generator.py +317 -0
  14. metaflow_extensions/outerbounds/plugins/apps/core/config/unified_config.py +994 -0
  15. metaflow_extensions/outerbounds/plugins/apps/core/config_schema.yaml +217 -211
  16. metaflow_extensions/outerbounds/plugins/apps/core/dependencies.py +3 -3
  17. metaflow_extensions/outerbounds/plugins/apps/core/deployer.py +132 -0
  18. metaflow_extensions/outerbounds/plugins/apps/core/experimental/__init__.py +4 -36
  19. metaflow_extensions/outerbounds/plugins/apps/core/perimeters.py +44 -2
  20. metaflow_extensions/outerbounds/plugins/apps/core/validations.py +4 -9
  21. metaflow_extensions/outerbounds/toplevel/global_aliases_for_metaflow_package.py +1 -0
  22. metaflow_extensions/outerbounds/toplevel/ob_internal.py +1 -0
  23. {ob_metaflow_extensions-1.1.175rc1.dist-info → ob_metaflow_extensions-1.1.175rc3.dist-info}/METADATA +1 -1
  24. {ob_metaflow_extensions-1.1.175rc1.dist-info → ob_metaflow_extensions-1.1.175rc3.dist-info}/RECORD +26 -20
  25. metaflow_extensions/outerbounds/plugins/apps/core/cli_to_config.py +0 -99
  26. metaflow_extensions/outerbounds/plugins/apps/core/config_schema_autogen.json +0 -336
  27. {ob_metaflow_extensions-1.1.175rc1.dist-info → ob_metaflow_extensions-1.1.175rc3.dist-info}/WHEEL +0 -0
  28. {ob_metaflow_extensions-1.1.175rc1.dist-info → ob_metaflow_extensions-1.1.175rc3.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,828 @@
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
+ parsing_fn: Optional[Callable] = None,
243
+ ):
244
+ if behavior == FieldBehavior.NOT_ALLOWED and ConfigMeta.is_instance(field_type):
245
+ raise ValueError(
246
+ "NOT_ALLOWED behavior cannot be set for ConfigMeta-based objects."
247
+ )
248
+ if callable(default) and not ConfigMeta.is_instance(field_type):
249
+ raise ValueError(
250
+ f"Default value for {field_type} is a callable but it is not a ConfigMeta-based object."
251
+ )
252
+
253
+ self.default = default
254
+ self.cli_meta = cli_meta
255
+ self.field_type = field_type
256
+ self.strict_types = strict_types
257
+ # Strict types means that the __set__ will raise an error if the type of the valu
258
+ # doesn't match the type of the field.
259
+ self.required = required
260
+ self.help = help
261
+ self.behavior = behavior
262
+ self.example = example
263
+ self.name = None
264
+ self.validation_fn = validation_fn
265
+ self.is_experimental = is_experimental
266
+ self.parsing_fn = parsing_fn
267
+ self._qual_name_stack = []
268
+
269
+ # This function allows config fields to be made aware of the
270
+ # owner instance's names. Its via in the ConfigMeta classes'
271
+ # _set_owner_instance function. But the _set_owner_instance gets
272
+ # called within the ConfigField's __set__ function
273
+ # (when the actual instance of the value is being set)
274
+ def _set_owner_name(self, owner_name: str):
275
+ self._qual_name_stack.append(owner_name)
276
+
277
+ def fully_qualified_name(self):
278
+ return ".".join(self._qual_name_stack + [self.name])
279
+
280
+ def __set_name__(self, owner, name):
281
+ self.name = name
282
+
283
+ def __get__(self, instance, owner):
284
+ if instance is None:
285
+ return self
286
+ # DEFAULTS need to be explicilty set to ensure that
287
+ # only explicitly set values are return on get
288
+ return instance.__dict__.get(self.name)
289
+
290
+ def __set__(self, instance, value):
291
+
292
+ if self.parsing_fn:
293
+ value = self.parsing_fn(value)
294
+
295
+ # TODO: handle this exception at top level if necessary.
296
+ if value is not None and self.strict_types and self.field_type is not None:
297
+ if not isinstance(value, self.field_type):
298
+ raise ValueError(
299
+ f"Value {value} is not of type {self.field_type} for the field {self.name}"
300
+ )
301
+
302
+ # We set the owner instance in the ConfigMeta based classes so they
303
+ # propagate it down to the ConfigField based classes.
304
+ if ConfigMeta.is_instance(value):
305
+ for x in self._qual_name_stack + [self.name]:
306
+ value._set_owner_instance(x)
307
+
308
+ instance.__dict__[self.name] = value
309
+
310
+ def __str__(self) -> str:
311
+ type_name = (
312
+ getattr(self.field_type, "__name__", "typing.Any")
313
+ if self.field_type
314
+ else "typing.Any"
315
+ )
316
+ return f"<ConfigField name='{self.name}' type={type_name} default={self.default!r}>"
317
+
318
+
319
+ class ConfigMeta(type):
320
+ """Metaclass implementing the configuration system's class transformation layer.
321
+
322
+ This metaclass exists to solve the fundamental problem of creating a declarative configuration
323
+ system that can automatically generate runtime behavior from field definitions. Without a
324
+ metaclass, each configuration class would need to manually implement field discovery,
325
+ validation, CLI integration, and nested object handling, leading to boilerplate code and
326
+ inconsistent behavior across the system.
327
+
328
+ Technical Implementation:
329
+
330
+ During class creation (__new__), this metaclass intercepts the class namespace and performs
331
+ several critical transformations:
332
+
333
+ 1. Field Discovery: Scans the class namespace for ConfigField instances and extracts their
334
+ metadata into a `_fields` registry. This registry becomes the source of truth for all
335
+ runtime operations including validation, CLI generation, and serialization.
336
+
337
+ 2. Method Injection: Adds the `_get_field` method to enable programmatic access to field
338
+ metadata. This method is used throughout the system by validation functions, CLI
339
+ generators, and configuration mergers.
340
+
341
+ 3. __init__ Override: Replaces the class's __init__ method with a standardized version that
342
+ handles three critical initialization phases:
343
+ - Field initialization to None (explicit defaulting happens later via apply_defaults)
344
+ - Nested config object instantiation for ConfigMeta-based field types
345
+ - Keyword argument processing for programmatic configuration
346
+
347
+ System Integration and Lifecycle:
348
+
349
+ The metaclass integrates with the broader configuration system through several key interfaces:
350
+
351
+ - populate_config_recursive: Uses the _fields registry to map external data sources
352
+ (CLI options, config files) to object attributes
353
+ - apply_defaults: Traverses the _fields registry to apply default values after population
354
+ - validate_config_meta: Uses field metadata to execute validation functions
355
+ - merge_field_values: Consults field behavior settings to determine merge strategies
356
+ - config_meta_to_dict: Converts instances back to dictionaries for serialization
357
+
358
+ Lifecycle Phases:
359
+
360
+ 1. Class Definition: Metaclass transforms the class, creating _fields registry
361
+ 2. Instance Creation: Auto-generated __init__ initializes fields and nested objects
362
+ 3. Population: External systems use _fields to populate from CLI/config files
363
+ 4. Validation: Field metadata drives validation and required field checking
364
+ 5. Default Application: Fields with None values receive their defaults
365
+ 6. Runtime Usage: Descriptor protocol provides controlled field access
366
+
367
+ Why a Metaclass:
368
+
369
+ The alternatives to a metaclass would be:
370
+ - Manual field registration in each class (error-prone, inconsistent)
371
+ - Inheritance-based approach (doesn't solve the field discovery problem)
372
+ - Decorator-based approach (requires manual application, less automatic)
373
+ - Runtime introspection (performance overhead, less reliable)
374
+
375
+ The metaclass provides automatic, consistent behavior while maintaining the declarative
376
+ syntax that makes configuration classes readable and maintainable.
377
+
378
+ Usage Pattern:
379
+ ```python
380
+ class MyConfig(metaclass=ConfigMeta):
381
+ name = ConfigField(field_type=str, required=True)
382
+ port = ConfigField(field_type=int, default=8080)
383
+ resources = ConfigField(field_type=ResourceConfig)
384
+ ```
385
+ """
386
+
387
+ @staticmethod
388
+ def is_instance(value) -> bool:
389
+ return hasattr(value, "_fields")
390
+
391
+ def __new__(mcs, name, bases, namespace):
392
+ # Collect field metadata
393
+ fields = {}
394
+ for key, value in namespace.items():
395
+ if isinstance(value, ConfigField):
396
+ fields[key] = value
397
+
398
+ def _set_owner_to_instance(self, instance_name: str):
399
+ for field_name, field_info in fields.items(): # field_info is a ConfigField
400
+ field_info._set_owner_name(instance_name)
401
+
402
+ # Store fields metadata on the class
403
+ namespace["_fields"] = fields
404
+
405
+ # Inject a function to get configField from a field name
406
+ def get_field(cls, field_name: str) -> ConfigField:
407
+ return fields[field_name]
408
+
409
+ namespace["_get_field"] = get_field
410
+ namespace["_set_owner_instance"] = _set_owner_to_instance
411
+
412
+ # Auto-generate __init__ method;
413
+ # Override it for all classes.
414
+ def __init__(self, **kwargs):
415
+ # Initialize all fields with Nones or other values
416
+ # We initiaze with None because defaulting should be a
417
+ # constant behavior
418
+ for field_name, field_info in fields.items():
419
+ default_value = None
420
+
421
+ # Handle nested config objects
422
+ if ConfigMeta.is_instance(field_info.field_type):
423
+ # Create nested config instance with defaults
424
+ default_value = field_info.field_type()
425
+
426
+ # Set from kwargs or use default
427
+ if field_name in kwargs:
428
+ setattr(self, field_name, kwargs[field_name])
429
+ else:
430
+ setattr(self, field_name, default_value)
431
+
432
+ return super().__new__(mcs, name, bases, namespace)
433
+
434
+
435
+ def apply_defaults(config) -> None:
436
+ """
437
+ Apply default values to any fields that are still None.
438
+
439
+ Args:
440
+ config: instance of a ConfigMeta object
441
+ """
442
+ for field_name, field_info in config._fields.items():
443
+ current_value = getattr(config, field_name, None)
444
+
445
+ if current_value is None:
446
+ # The nested configs will never be set to None
447
+ # Since we always override the init function.
448
+ # The init function will always instantiate the
449
+ # sub-objects under the class
450
+ # Set default value for regular fields
451
+ setattr(config, field_name, field_info.default)
452
+ elif ConfigMeta.is_instance(field_info.field_type) and ConfigMeta.is_instance(
453
+ current_value
454
+ ):
455
+
456
+ # Apply defaults to nested config (to any sub values that might need it)
457
+ apply_defaults(current_value)
458
+ # also apply defaults to the current value if a defaults callable is provided.
459
+ # Certain top level config fields might require default setting based on the
460
+ # what the current value in the fields is set.
461
+ if field_info.default:
462
+ field_info.default(current_value)
463
+
464
+
465
+ class ConfigValidationFailedException(Exception):
466
+ def __init__(
467
+ self,
468
+ field_name: str,
469
+ field_info: ConfigField,
470
+ current_value,
471
+ message: Optional[str] = None,
472
+ ):
473
+ self.field_name = field_name
474
+ self.field_info = field_info
475
+ self.current_value = current_value
476
+ self.message = (
477
+ f"Validation failed for field {field_name} with value {current_value}"
478
+ )
479
+ if message is not None:
480
+ self.message = message
481
+
482
+ suffix = "\n\tThis configuration is set via the the following interfaces:\n\n"
483
+ suffix += "\t\t1. Config file: `%s`\n" % field_info.fully_qualified_name()
484
+ suffix += (
485
+ "\t\t2. Programatic API (Python): `%s`\n"
486
+ % field_info.fully_qualified_name()
487
+ )
488
+ if field_info.cli_meta:
489
+ suffix += "\t\t3. CLI: `%s`\n" % field_info.cli_meta.cli_option_str
490
+
491
+ self.message += suffix
492
+
493
+ super().__init__(self.message)
494
+
495
+
496
+ class RequiredFieldMissingException(ConfigValidationFailedException):
497
+ pass
498
+
499
+
500
+ class MergingNotAllowedFieldsException(ConfigValidationFailedException):
501
+ def __init__(
502
+ self,
503
+ field_name: str,
504
+ field_info: ConfigField,
505
+ current_value: Any,
506
+ override_value: Any,
507
+ ):
508
+ super().__init__(
509
+ field_name=field_name,
510
+ field_info=field_info,
511
+ current_value=current_value,
512
+ message=f"Merging not allowed for field {field_name} with value {current_value} and override value {override_value}",
513
+ )
514
+ self.override_value = override_value
515
+
516
+
517
+ def validate_required_fields(config_instance):
518
+ for field_name, field_info in config_instance._fields.items():
519
+ if field_info.required:
520
+ current_value = getattr(config_instance, field_name, None)
521
+ if current_value is None:
522
+ raise RequiredFieldMissingException(
523
+ field_name, field_info, current_value
524
+ )
525
+ if ConfigMeta.is_instance(field_info.field_type) and ConfigMeta.is_instance(
526
+ current_value
527
+ ):
528
+ validate_required_fields(current_value)
529
+ # TODO: Fix the exception handling over here.
530
+
531
+
532
+ def validate_config_meta(config_instance):
533
+ for field_name, field_info in config_instance._fields.items():
534
+ current_value = getattr(config_instance, field_name, None)
535
+
536
+ if ConfigMeta.is_instance(field_info.field_type) and ConfigMeta.is_instance(
537
+ current_value
538
+ ):
539
+ validate_config_meta(current_value)
540
+
541
+ if field_info.validation_fn:
542
+ if not field_info.validation_fn(current_value):
543
+ raise ConfigValidationFailedException(
544
+ field_name, field_info, current_value
545
+ )
546
+
547
+
548
+ def config_meta_to_dict(config_instance) -> Optional[Dict[str, Any]]:
549
+ """Convert a configuration instance to a nested dictionary.
550
+
551
+ Recursively converts ConfigMeta-based configuration instances to dictionaries,
552
+ handling nested config objects and preserving the structure.
553
+
554
+ Args:
555
+ config_instance: Instance of a ConfigMeta-based configuration class
556
+
557
+ Returns:
558
+ Nested dictionary representation of the configuration
559
+
560
+ Examples:
561
+ # Convert a config instance to dict
562
+
563
+ config_dict = to_dict(config)
564
+
565
+ # Result will be:
566
+ # {
567
+ # "name": "myapp",
568
+ # "port": 8000,
569
+ # "resources": {
570
+ # "cpu": "500m",
571
+ # "memory": "1Gi",
572
+ # "gpu": None,
573
+ # "disk": "20Gi"
574
+ # },
575
+ # "auth": None,
576
+ # ...
577
+ # }
578
+ """
579
+ if config_instance is None:
580
+ return None
581
+
582
+ # Check if this is a ConfigMeta-based class
583
+ if not ConfigMeta.is_instance(config_instance):
584
+ # If it's not a config object, return as-is
585
+ return config_instance
586
+
587
+ result = {}
588
+
589
+ # Iterate through all fields defined in the class
590
+ for field_name, field_info in config_instance._fields.items():
591
+ # Get the current value
592
+ value = getattr(config_instance, field_name, None)
593
+
594
+ # Handle nested config objects recursively
595
+ if value is not None and ConfigMeta.is_instance(value):
596
+ # It's a nested config object
597
+ result[field_name] = config_meta_to_dict(value)
598
+ elif isinstance(value, list) and value:
599
+ # Handle lists that might contain config objects
600
+ result[field_name] = [
601
+ config_meta_to_dict(item) if ConfigMeta.is_instance(item) else item
602
+ for item in value
603
+ ]
604
+ elif isinstance(value, dict) and value:
605
+ # Handle dictionaries that might contain config objects
606
+ result[field_name] = {
607
+ k: config_meta_to_dict(v) if ConfigMeta.is_instance(v) else v
608
+ for k, v in value.items()
609
+ }
610
+ else:
611
+ # Primitive type or None
612
+ result[field_name] = value
613
+
614
+ return result
615
+
616
+
617
+ def merge_field_values(
618
+ base_value: Any, override_value: Any, field_info, behavior: str
619
+ ) -> Any:
620
+ """
621
+ Merge individual field values based on behavior.
622
+
623
+ Args:
624
+ base_value: Value from base config
625
+ override_value: Value from override config
626
+ field_info: Field metadata
627
+ behavior: FieldBehavior for this field
628
+
629
+ Returns:
630
+ Merged value
631
+ """
632
+ # Handle NOT_ALLOWED behavior
633
+ if behavior == FieldBehavior.NOT_ALLOWED:
634
+ if base_value is not None and override_value is not None:
635
+ raise MergingNotAllowedFieldsException(
636
+ field_name=field_info.name,
637
+ field_info=field_info,
638
+ current_value=base_value,
639
+ override_value=override_value,
640
+ )
641
+ if base_value is None:
642
+ return override_value
643
+ return base_value
644
+
645
+ # Handle UNION behavior (default)
646
+ if behavior == FieldBehavior.UNION:
647
+ # If override is None, use base value
648
+ if override_value is None:
649
+ return (
650
+ base_value if base_value is not None else None
651
+ ) # We will not set defaults!
652
+
653
+ # If base is None, use override value
654
+ if base_value is None:
655
+ return override_value
656
+
657
+ # Handle nested config objects
658
+ if ConfigMeta.is_instance(field_info.field_type):
659
+ if isinstance(base_value, field_info.field_type) and isinstance(
660
+ override_value, field_info.field_type
661
+ ):
662
+ # Merge nested configs recursively
663
+ merged_nested = field_info.field_type()
664
+ for (
665
+ nested_field_name,
666
+ nested_field_info,
667
+ ) in field_info.field_type._fields.items():
668
+ nested_base = getattr(base_value, nested_field_name, None)
669
+ nested_override = getattr(override_value, nested_field_name, None)
670
+ nested_behavior = getattr(
671
+ nested_field_info, "behavior", FieldBehavior.UNION
672
+ )
673
+
674
+ merged_nested_value = merge_field_values(
675
+ nested_base, nested_override, nested_field_info, nested_behavior
676
+ )
677
+ setattr(merged_nested, nested_field_name, merged_nested_value)
678
+
679
+ return merged_nested
680
+ else:
681
+ # One is not a config object, use override
682
+ return override_value
683
+
684
+ # Handle lists (union behavior merges lists)
685
+ if isinstance(base_value, list) and isinstance(override_value, list):
686
+ # Merge lists by extending
687
+ merged_list = base_value.copy()
688
+ merged_list.extend(override_value)
689
+ return merged_list
690
+
691
+ # Handle dicts (union behavior merges dicts)
692
+ if isinstance(base_value, dict) and isinstance(override_value, dict):
693
+ merged_dict = base_value.copy()
694
+ merged_dict.update(override_value)
695
+ return merged_dict
696
+
697
+ # For other types, override takes precedence
698
+ return override_value
699
+
700
+ # Default: override takes precedence
701
+ return (
702
+ override_value
703
+ if override_value is not None
704
+ else (base_value if base_value is not None else None)
705
+ )
706
+
707
+
708
+ class JsonFriendlyKeyValuePair(click.ParamType): # type: ignore
709
+ name = "KV-PAIR" # type: ignore
710
+
711
+ def convert(self, value, param, ctx):
712
+ # Parse a string of the form KEY=VALUE into a dict {KEY: VALUE}
713
+ if len(value.split("=", 1)) != 2:
714
+ self.fail(
715
+ f"Invalid format for {value}. Expected format: KEY=VALUE", param, ctx
716
+ )
717
+
718
+ key, _value = value.split("=", 1)
719
+ try:
720
+ return {key: json.loads(_value)}
721
+ except json.JSONDecodeError:
722
+ return {key: _value}
723
+ except Exception as e:
724
+ self.fail(f"Invalid value for {value}. Error: {e}", param, ctx)
725
+
726
+ def __str__(self):
727
+ return repr(self)
728
+
729
+ def __repr__(self):
730
+ return "KV-PAIR"
731
+
732
+
733
+ class CommaSeparatedList(click.ParamType): # type: ignore
734
+ name = "COMMA-SEPARATED-LIST" # type: ignore
735
+
736
+ def convert(self, value, param, ctx):
737
+ if isinstance(value, list):
738
+ return value
739
+ if isinstance(value, str):
740
+ return [item.strip() for item in value.split(",") if item.strip()]
741
+ return value
742
+
743
+ def __str__(self):
744
+ return repr(self)
745
+
746
+ def __repr__(self):
747
+ return "COMMA-SEPARATED-LIST"
748
+
749
+
750
+ class PureStringKVPair(click.ParamType): # type: ignore
751
+ """Click type for key-value pairs (KEY=VALUE)."""
752
+
753
+ name = "key=value"
754
+
755
+ def convert(self, value, param, ctx):
756
+ if isinstance(value, dict):
757
+ return value
758
+ try:
759
+ key, val = value.split("=", 1)
760
+ return {key: val}
761
+ except ValueError:
762
+ self.fail(f"'{value}' is not a valid key=value pair", param, ctx)
763
+
764
+
765
+ PureStringKVPairType = PureStringKVPair()
766
+ CommaSeparatedListType = CommaSeparatedList()
767
+ JsonFriendlyKeyValuePairType = JsonFriendlyKeyValuePair()
768
+
769
+
770
+ def populate_config_recursive(
771
+ config_instance,
772
+ config_class,
773
+ source_data,
774
+ get_source_key_fn,
775
+ get_source_value_fn,
776
+ ):
777
+ """
778
+ Recursively populate a config instance from source data.
779
+
780
+ Args:
781
+ config_instance: Config object to populate
782
+ config_class: Class of the config object
783
+ source_data: Source data (dict, CLI options, etc.)
784
+ get_source_key_fn: Function to get the source key for a field
785
+ get_source_value_fn: Function to get the value from source for a key
786
+ """
787
+
788
+ for field_name, field_info in config_class._fields.items():
789
+ # When we populate the ConfigMeta based objects, we want to do the following:
790
+ # If we find some key associated to the object inside the source data, then we populate the object
791
+ # with it. If that key corresponds to a nested config meta object then we just recusively pass down the
792
+ # value of the key from source data and populate the nested object other wise just end up setting the object
793
+ source_key = get_source_key_fn(field_name, field_info)
794
+ if source_key and source_key in source_data:
795
+ value = get_source_value_fn(source_data, source_key)
796
+ if value is not None:
797
+ # Handle nested config objects (for dict sources with nested data)
798
+ if ConfigMeta.is_instance(field_info.field_type) and isinstance(
799
+ value, dict
800
+ ):
801
+ nested_config = field_info.field_type()
802
+ populate_config_recursive(
803
+ nested_config,
804
+ field_info.field_type,
805
+ value, # For dict, use the nested dict as source
806
+ get_source_key_fn,
807
+ get_source_value_fn,
808
+ )
809
+ setattr(config_instance, field_name, nested_config)
810
+ else:
811
+ # Direct value assignment for regular fields
812
+ setattr(config_instance, field_name, value)
813
+ elif ConfigMeta.is_instance(field_info.field_type):
814
+ # It might be possible that the source key that respresents a subfield is
815
+ # is present at the root level but there is no field pertainig to the ConfigMeta
816
+ # itself. So always recurisvely check the subtree and no matter what keep instantiating
817
+ # any nested config objects.
818
+ _nested_config = field_info.field_type()
819
+ populate_config_recursive(
820
+ _nested_config,
821
+ field_info.field_type,
822
+ source_data,
823
+ get_source_key_fn,
824
+ get_source_value_fn,
825
+ )
826
+ setattr(config_instance, field_name, _nested_config)
827
+ else:
828
+ pass