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