ob-metaflow-extensions 1.1.130__py2.py3-none-any.whl → 1.5.1__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 (105) hide show
  1. metaflow_extensions/outerbounds/__init__.py +1 -1
  2. metaflow_extensions/outerbounds/plugins/__init__.py +34 -4
  3. metaflow_extensions/outerbounds/plugins/apps/__init__.py +0 -0
  4. metaflow_extensions/outerbounds/plugins/apps/app_cli.py +0 -0
  5. metaflow_extensions/outerbounds/plugins/apps/app_utils.py +187 -0
  6. metaflow_extensions/outerbounds/plugins/apps/consts.py +3 -0
  7. metaflow_extensions/outerbounds/plugins/apps/core/__init__.py +15 -0
  8. metaflow_extensions/outerbounds/plugins/apps/core/_state_machine.py +506 -0
  9. metaflow_extensions/outerbounds/plugins/apps/core/_vendor/__init__.py +0 -0
  10. metaflow_extensions/outerbounds/plugins/apps/core/_vendor/spinner/__init__.py +4 -0
  11. metaflow_extensions/outerbounds/plugins/apps/core/_vendor/spinner/spinners.py +478 -0
  12. metaflow_extensions/outerbounds/plugins/apps/core/app_config.py +128 -0
  13. metaflow_extensions/outerbounds/plugins/apps/core/app_deploy_decorator.py +330 -0
  14. metaflow_extensions/outerbounds/plugins/apps/core/artifacts.py +0 -0
  15. metaflow_extensions/outerbounds/plugins/apps/core/capsule.py +958 -0
  16. metaflow_extensions/outerbounds/plugins/apps/core/click_importer.py +24 -0
  17. metaflow_extensions/outerbounds/plugins/apps/core/code_package/__init__.py +3 -0
  18. metaflow_extensions/outerbounds/plugins/apps/core/code_package/code_packager.py +618 -0
  19. metaflow_extensions/outerbounds/plugins/apps/core/code_package/examples.py +125 -0
  20. metaflow_extensions/outerbounds/plugins/apps/core/config/__init__.py +15 -0
  21. metaflow_extensions/outerbounds/plugins/apps/core/config/cli_generator.py +165 -0
  22. metaflow_extensions/outerbounds/plugins/apps/core/config/config_utils.py +966 -0
  23. metaflow_extensions/outerbounds/plugins/apps/core/config/schema_export.py +299 -0
  24. metaflow_extensions/outerbounds/plugins/apps/core/config/typed_configs.py +233 -0
  25. metaflow_extensions/outerbounds/plugins/apps/core/config/typed_init_generator.py +537 -0
  26. metaflow_extensions/outerbounds/plugins/apps/core/config/unified_config.py +1125 -0
  27. metaflow_extensions/outerbounds/plugins/apps/core/config_schema.yaml +337 -0
  28. metaflow_extensions/outerbounds/plugins/apps/core/dependencies.py +115 -0
  29. metaflow_extensions/outerbounds/plugins/apps/core/deployer.py +959 -0
  30. metaflow_extensions/outerbounds/plugins/apps/core/experimental/__init__.py +89 -0
  31. metaflow_extensions/outerbounds/plugins/apps/core/perimeters.py +87 -0
  32. metaflow_extensions/outerbounds/plugins/apps/core/secrets.py +164 -0
  33. metaflow_extensions/outerbounds/plugins/apps/core/utils.py +233 -0
  34. metaflow_extensions/outerbounds/plugins/apps/core/validations.py +17 -0
  35. metaflow_extensions/outerbounds/plugins/apps/deploy_decorator.py +201 -0
  36. metaflow_extensions/outerbounds/plugins/apps/supervisord_utils.py +243 -0
  37. metaflow_extensions/outerbounds/plugins/aws/__init__.py +4 -0
  38. metaflow_extensions/outerbounds/plugins/aws/assume_role.py +3 -0
  39. metaflow_extensions/outerbounds/plugins/aws/assume_role_decorator.py +118 -0
  40. metaflow_extensions/outerbounds/plugins/card_utilities/injector.py +1 -1
  41. metaflow_extensions/outerbounds/plugins/checkpoint_datastores/__init__.py +2 -0
  42. metaflow_extensions/outerbounds/plugins/checkpoint_datastores/coreweave.py +71 -0
  43. metaflow_extensions/outerbounds/plugins/checkpoint_datastores/external_chckpt.py +85 -0
  44. metaflow_extensions/outerbounds/plugins/checkpoint_datastores/nebius.py +73 -0
  45. metaflow_extensions/outerbounds/plugins/fast_bakery/baker.py +110 -0
  46. metaflow_extensions/outerbounds/plugins/fast_bakery/docker_environment.py +43 -9
  47. metaflow_extensions/outerbounds/plugins/fast_bakery/fast_bakery.py +12 -0
  48. metaflow_extensions/outerbounds/plugins/kubernetes/kubernetes_client.py +18 -44
  49. metaflow_extensions/outerbounds/plugins/kubernetes/pod_killer.py +374 -0
  50. metaflow_extensions/outerbounds/plugins/nim/card.py +2 -16
  51. metaflow_extensions/outerbounds/plugins/nim/{__init__.py → nim_decorator.py} +13 -49
  52. metaflow_extensions/outerbounds/plugins/nim/nim_manager.py +294 -233
  53. metaflow_extensions/outerbounds/plugins/nim/utils.py +36 -0
  54. metaflow_extensions/outerbounds/plugins/nvcf/constants.py +2 -2
  55. metaflow_extensions/outerbounds/plugins/nvcf/nvcf.py +100 -19
  56. metaflow_extensions/outerbounds/plugins/nvcf/nvcf_decorator.py +6 -1
  57. metaflow_extensions/outerbounds/plugins/nvct/__init__.py +0 -0
  58. metaflow_extensions/outerbounds/plugins/nvct/exceptions.py +71 -0
  59. metaflow_extensions/outerbounds/plugins/nvct/nvct.py +131 -0
  60. metaflow_extensions/outerbounds/plugins/nvct/nvct_cli.py +289 -0
  61. metaflow_extensions/outerbounds/plugins/nvct/nvct_decorator.py +286 -0
  62. metaflow_extensions/outerbounds/plugins/nvct/nvct_runner.py +218 -0
  63. metaflow_extensions/outerbounds/plugins/nvct/utils.py +29 -0
  64. metaflow_extensions/outerbounds/plugins/ollama/__init__.py +225 -0
  65. metaflow_extensions/outerbounds/plugins/ollama/constants.py +1 -0
  66. metaflow_extensions/outerbounds/plugins/ollama/exceptions.py +22 -0
  67. metaflow_extensions/outerbounds/plugins/ollama/ollama.py +1924 -0
  68. metaflow_extensions/outerbounds/plugins/ollama/status_card.py +292 -0
  69. metaflow_extensions/outerbounds/plugins/optuna/__init__.py +48 -0
  70. metaflow_extensions/outerbounds/plugins/profilers/simple_card_decorator.py +96 -0
  71. metaflow_extensions/outerbounds/plugins/s3_proxy/__init__.py +7 -0
  72. metaflow_extensions/outerbounds/plugins/s3_proxy/binary_caller.py +132 -0
  73. metaflow_extensions/outerbounds/plugins/s3_proxy/constants.py +11 -0
  74. metaflow_extensions/outerbounds/plugins/s3_proxy/exceptions.py +13 -0
  75. metaflow_extensions/outerbounds/plugins/s3_proxy/proxy_bootstrap.py +59 -0
  76. metaflow_extensions/outerbounds/plugins/s3_proxy/s3_proxy_api.py +93 -0
  77. metaflow_extensions/outerbounds/plugins/s3_proxy/s3_proxy_decorator.py +250 -0
  78. metaflow_extensions/outerbounds/plugins/s3_proxy/s3_proxy_manager.py +225 -0
  79. metaflow_extensions/outerbounds/plugins/secrets/secrets.py +38 -2
  80. metaflow_extensions/outerbounds/plugins/snowflake/snowflake.py +81 -11
  81. metaflow_extensions/outerbounds/plugins/snowpark/snowpark.py +18 -8
  82. metaflow_extensions/outerbounds/plugins/snowpark/snowpark_cli.py +6 -0
  83. metaflow_extensions/outerbounds/plugins/snowpark/snowpark_client.py +45 -18
  84. metaflow_extensions/outerbounds/plugins/snowpark/snowpark_decorator.py +18 -9
  85. metaflow_extensions/outerbounds/plugins/snowpark/snowpark_job.py +10 -4
  86. metaflow_extensions/outerbounds/plugins/torchtune/__init__.py +163 -0
  87. metaflow_extensions/outerbounds/plugins/vllm/__init__.py +255 -0
  88. metaflow_extensions/outerbounds/plugins/vllm/constants.py +1 -0
  89. metaflow_extensions/outerbounds/plugins/vllm/exceptions.py +1 -0
  90. metaflow_extensions/outerbounds/plugins/vllm/status_card.py +352 -0
  91. metaflow_extensions/outerbounds/plugins/vllm/vllm_manager.py +621 -0
  92. metaflow_extensions/outerbounds/remote_config.py +46 -9
  93. metaflow_extensions/outerbounds/toplevel/global_aliases_for_metaflow_package.py +94 -2
  94. metaflow_extensions/outerbounds/toplevel/ob_internal.py +4 -0
  95. metaflow_extensions/outerbounds/toplevel/plugins/ollama/__init__.py +1 -0
  96. metaflow_extensions/outerbounds/toplevel/plugins/optuna/__init__.py +1 -0
  97. metaflow_extensions/outerbounds/toplevel/plugins/torchtune/__init__.py +1 -0
  98. metaflow_extensions/outerbounds/toplevel/plugins/vllm/__init__.py +1 -0
  99. metaflow_extensions/outerbounds/toplevel/s3_proxy.py +88 -0
  100. {ob_metaflow_extensions-1.1.130.dist-info → ob_metaflow_extensions-1.5.1.dist-info}/METADATA +2 -2
  101. ob_metaflow_extensions-1.5.1.dist-info/RECORD +133 -0
  102. metaflow_extensions/outerbounds/plugins/nim/utilities.py +0 -5
  103. ob_metaflow_extensions-1.1.130.dist-info/RECORD +0 -56
  104. {ob_metaflow_extensions-1.1.130.dist-info → ob_metaflow_extensions-1.5.1.dist-info}/WHEEL +0 -0
  105. {ob_metaflow_extensions-1.1.130.dist-info → ob_metaflow_extensions-1.5.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,966 @@
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 ConfigFieldContext:
66
+ """
67
+ Defines which interfaces a ConfigField is available in.
68
+
69
+ ConfigFieldContext controls whether a field appears in the CLI, programmatic API, or both.
70
+ This allows the same CoreConfig class to serve different interfaces while keeping
71
+ interface-specific fields properly scoped.
72
+
73
+ Context Types:
74
+
75
+ ALL (Default):
76
+ Field is available in both CLI and programmatic API.
77
+ Most configuration fields fall into this category.
78
+
79
+ Example:
80
+ ```python
81
+ name = ConfigField(
82
+ field_type=str,
83
+ # available_in=ConfigFieldContext.ALL is the default
84
+ )
85
+ ```
86
+
87
+ CLI:
88
+ Field is only available in the CLI interface.
89
+ Use for CLI-specific options that don't make sense programmatically.
90
+
91
+ Example:
92
+ ```python
93
+ # config_file path only makes sense when running from CLI
94
+ config_file = ConfigField(
95
+ cli_meta=CLIOption(name="config_file", cli_option_str="--config-file"),
96
+ field_type=str,
97
+ available_in=ConfigFieldContext.CLI,
98
+ )
99
+ ```
100
+
101
+ PROGRAMMATIC:
102
+ Field is only available in the programmatic API.
103
+ Use for fields that accept programmatic-only types (like PackagedCode)
104
+ or don't map well to CLI options.
105
+
106
+ Example:
107
+ ```python
108
+ # code_package accepts PackagedCode namedtuple from package_code()
109
+ code_package = ConfigField(
110
+ field_type=PackagedCode,
111
+ available_in=ConfigFieldContext.PROGRAMMATIC,
112
+ )
113
+ ```
114
+
115
+ Integration:
116
+ - CLI generators check `available_in` to decide whether to generate CLI options
117
+ - TypedConfig generators check `available_in` to decide whether to include in __init__
118
+ - Schema exporters can filter fields based on target interface
119
+ """
120
+
121
+ ALL = "all" # Available in both CLI and programmatic API (default)
122
+ CLI = "cli" # Only available in CLI
123
+ PROGRAMMATIC = "programmatic" # Only available in programmatic API
124
+
125
+
126
+ class CLIOption:
127
+ """Metadata container for automatic CLI option generation from configuration fields.
128
+
129
+ CLIOption defines how a ConfigField should be exposed as a command-line option in the
130
+ generated CLI interface. It provides a declarative way to specify CLI parameter names,
131
+ help text, validation rules, and Click-specific behaviors without tightly coupling
132
+ configuration definitions to CLI implementation details.
133
+
134
+ This class bridges the gap between configuration field definitions and Click option
135
+ generation, allowing the same field definition to work seamlessly across different
136
+ interfaces (CLI, config files, programmatic usage).
137
+
138
+ Click Integration:
139
+ The CLIOption metadata is used by CLIGenerator to create Click options:
140
+ ```python
141
+ @click.option("--port", "port", type=int, help="Application port")
142
+ ```
143
+
144
+ This is automatically generated from:
145
+ ```python
146
+ port = ConfigField(
147
+ cli_meta=CLIOption(
148
+ name="port",
149
+ cli_option_str="--port",
150
+ help="Application port"
151
+ ),
152
+ field_type=int
153
+ )
154
+ ```
155
+
156
+
157
+ Parameters
158
+ ----------
159
+ name : str
160
+ Parameter name used in Click option and function signature (e.g., "my_foo").
161
+ cli_option_str : str
162
+ Command-line option string (e.g., "--foo", "--enable/--disable").
163
+ help : Optional[str], optional
164
+ Help text displayed in CLI help output.
165
+ short : Optional[str], optional
166
+ Short option character (e.g., "-f" for "--foo").
167
+ multiple : bool, optional
168
+ Whether the option accepts multiple values.
169
+ is_flag : bool, optional
170
+ Whether this is a boolean flag option.
171
+ choices : Optional[List[str]], optional
172
+ List of valid choices for the option.
173
+ default : Any, optional
174
+ Default value for the CLI option (separate from ConfigField default).
175
+ hidden : bool, optional
176
+ Whether to hide this option from CLI (config file only).
177
+ click_type : Optional[Any], optional
178
+ Custom Click type for specialized parsing (e.g., KeyValuePair).
179
+ """
180
+
181
+ def __init__(
182
+ self,
183
+ name: str, # This name corresponds to the `"my_foo"` in `@click.option("--foo","my_foo")`
184
+ cli_option_str: str, # This corresponds to the `--foo` in the `@click.option("--foo","my_foo")`
185
+ help: Optional[str] = None,
186
+ short: Optional[str] = None,
187
+ multiple: bool = False,
188
+ is_flag: bool = False,
189
+ choices: Optional[List[str]] = None,
190
+ default: Any = None,
191
+ hidden: bool = False,
192
+ click_type: Optional[Any] = None,
193
+ ):
194
+ self.name = name
195
+ self.cli_option_str = cli_option_str
196
+ self.help = help
197
+ self.short = short
198
+ self.multiple = multiple
199
+ self.is_flag = is_flag
200
+ self.choices = choices
201
+ self.default = default
202
+ self.hidden = hidden
203
+ self.click_type = click_type
204
+
205
+
206
+ class ConfigField:
207
+ """Descriptor for configuration fields with comprehensive metadata and behavior control.
208
+
209
+ ConfigField is a Python descriptor that provides a declarative way to define configuration
210
+ fields with rich metadata, validation, CLI integration, and merging behavior. It acts as
211
+ both a data descriptor (controlling get/set access) and a metadata container.
212
+
213
+ Key Functionality:
214
+ - **Descriptor Protocol**: Implements __get__, __set__, and __set_name__ to control
215
+ field access and automatically capture the field name during class creation.
216
+ - **Type Safety**: Optional strict type checking during value assignment.
217
+ - **CLI Integration**: Automatic CLI option generation via CLIOption metadata.
218
+ - **Validation**: Built-in validation functions and required field checks.
219
+ - **Merging Behavior**: Controls how values are merged from different sources (CLI, config files).
220
+ - **Default Values**: Supports both static defaults and callable defaults for dynamic initialization.
221
+
222
+ Merging Behaviors:
223
+ - **UNION**: Values from different sources are merged (lists extended, dicts updated).
224
+ - **NOT_ALLOWED**: Override values are ignored if base value exists.
225
+
226
+ Field Lifecycle:
227
+ 1. **Definition**: Field is defined in a ConfigMeta-based class
228
+ 2. **Registration**: __set_name__ is called to register the field name
229
+ 3. **Initialization**: Field is initialized with None or nested config objects
230
+ 4. **Population**: Values are set from CLI options, config files, or direct assignment
231
+ 5. **Validation**: Validation functions are called during commit phase
232
+ 6. **Default Application**: Default values are applied to None fields
233
+
234
+ Examples:
235
+ Basic field definition:
236
+ ```python
237
+ name = ConfigField(
238
+ field_type=str,
239
+ required=True,
240
+ help="Application name",
241
+ example="myapp"
242
+ )
243
+ ```
244
+
245
+ Field with CLI integration:
246
+ ```python
247
+ port = ConfigField(
248
+ cli_meta=CLIOption(
249
+ name="port",
250
+ cli_option_str="--port",
251
+ help="Application port"
252
+ ),
253
+ field_type=int,
254
+ required=True,
255
+ validation_fn=lambda x: 1 <= x <= 65535
256
+ )
257
+ ```
258
+
259
+ Nested configuration field:
260
+ ```python
261
+ resources = ConfigField(
262
+ field_type=ResourceConfig,
263
+ help="Resource configuration"
264
+ )
265
+ ```
266
+
267
+ Parameters
268
+ ----------
269
+ default : Any or Callable[["ConfigField"], Any], optional
270
+ Default value for the field. Can be a static value or a callable for dynamic defaults.
271
+ cli_meta : CLIOption, optional
272
+ CLIOption instance defining CLI option generation parameters.
273
+ field_type : type, optional
274
+ Expected type of the field value (used for validation and nesting).
275
+ required : bool, optional
276
+ Whether the field must have a non-None value after configuration.
277
+ help : str, optional
278
+ Help text describing the field's purpose.
279
+ behavior : str, optional
280
+ FieldBehavior controlling how values are merged from different sources.
281
+ example : Any, optional
282
+ Example value for documentation and schema generation.
283
+ strict_types : bool, optional
284
+ Whether to enforce type checking during value assignment.
285
+ validation_fn : callable, optional
286
+ Optional function to validate field values.
287
+ is_experimental : bool, optional
288
+ Whether this field is experimental (for documentation).
289
+ available_in : str, optional
290
+ ConfigFieldContext specifying which interfaces this field is available in.
291
+ One of ConfigFieldContext.ALL (default), ConfigFieldContext.CLI, or ConfigFieldContext.PROGRAMMATIC.
292
+ """
293
+
294
+ def __init__(
295
+ self,
296
+ default: Union[Any, Callable[["ConfigField"], Any]] = None,
297
+ cli_meta=None,
298
+ field_type=None,
299
+ required=False,
300
+ help=None,
301
+ behavior: str = FieldBehavior.UNION,
302
+ example=None,
303
+ strict_types=True,
304
+ validation_fn: Optional[Callable] = None,
305
+ is_experimental=False, # This property is for bookkeeping purposes and for export in schema.
306
+ parsing_fn: Optional[Callable] = None,
307
+ available_in: str = ConfigFieldContext.ALL,
308
+ ):
309
+ if behavior == FieldBehavior.NOT_ALLOWED and ConfigMeta.is_instance(field_type):
310
+ raise ValueError(
311
+ "NOT_ALLOWED behavior cannot be set for ConfigMeta-based objects."
312
+ )
313
+ if callable(default) and not ConfigMeta.is_instance(field_type):
314
+ raise ValueError(
315
+ f"Default value for {field_type} is a callable but it is not a ConfigMeta-based object."
316
+ )
317
+
318
+ self.default = default
319
+ self.cli_meta = cli_meta
320
+ self.field_type = field_type
321
+ self.strict_types = strict_types
322
+ # Strict types means that the __set__ will raise an error if the type of the valu
323
+ # doesn't match the type of the field.
324
+ self.required = required
325
+ self.help = help
326
+ self.behavior = behavior
327
+ self.example = example
328
+ self.name = None
329
+ self.validation_fn = validation_fn
330
+ self.is_experimental = is_experimental
331
+ self.parsing_fn = parsing_fn
332
+ self.available_in = available_in
333
+ self._qual_name_stack = []
334
+
335
+ # This function allows config fields to be made aware of the
336
+ # owner instance's names. It's called from the `commit_owner_names_across_tree`
337
+ # Decorator. Its called once the config instance is completely ready and
338
+ # it wil not have any further runtime instance modifications done to it.
339
+ # The core intent is to ensure that the full config lineage tree is captured and
340
+ # we have a full trace of where the config is coming from so that we can showcase it
341
+ # to users when they make configurational errors. It also allows us to reference those
342
+ # config values in the error messages across different types of errors.
343
+ def _set_owner_name(self, owner_name: str):
344
+ self._qual_name_stack.append(owner_name)
345
+
346
+ def fully_qualified_name(self):
347
+ return ".".join(self._qual_name_stack + [self.name])
348
+
349
+ def is_available_in_cli(self) -> bool:
350
+ """Check if this field should be available in the CLI interface."""
351
+ return self.available_in in (ConfigFieldContext.ALL, ConfigFieldContext.CLI)
352
+
353
+ def is_available_in_programmatic(self) -> bool:
354
+ """Check if this field should be available in the programmatic API."""
355
+ return self.available_in in (
356
+ ConfigFieldContext.ALL,
357
+ ConfigFieldContext.PROGRAMMATIC,
358
+ )
359
+
360
+ def __set_name__(self, owner, name):
361
+ self.name = name
362
+
363
+ def __get__(self, instance, owner):
364
+ if instance is None:
365
+ return self
366
+ # DEFAULTS need to be explicilty set to ensure that
367
+ # only explicitly set values are return on get
368
+ return instance.__dict__.get(self.name)
369
+
370
+ def __set__(self, instance, value):
371
+ if self.parsing_fn:
372
+ value = self.parsing_fn(value)
373
+
374
+ # TODO: handle this exception at top level if necessary.
375
+ if value is not None and self.strict_types and self.field_type is not None:
376
+ if not isinstance(value, self.field_type):
377
+ raise ValueError(
378
+ f"Value {value} is not of type {self.field_type} for the field {self.name}"
379
+ )
380
+
381
+ instance.__dict__[self.name] = value
382
+
383
+ def __str__(self) -> str:
384
+ type_name = (
385
+ getattr(self.field_type, "__name__", "typing.Any")
386
+ if self.field_type
387
+ else "typing.Any"
388
+ )
389
+ return f"<ConfigField name='{self.name}' type={type_name} default={self.default!r}>"
390
+
391
+
392
+ # Add this decorator function before the ConfigMeta class
393
+ # One of the core utilities of the ConfigMeta class
394
+ # is that we can track the tree of elements in the Config
395
+ # class that allow us to make those visible at runtime when
396
+ # the user has some configurational error. it also allows us to
397
+ # Figure out what the user is trying to extactly configure and where
398
+ # that configuration is coming from.
399
+ # Hence this decorator is set on what ever configMeta class based function
400
+ # so that when it gets called, the full call tree is properly set.
401
+ def commit_owner_names_across_tree(func):
402
+ """
403
+ Decorator that commits owner names across the configuration tree before executing the decorated function.
404
+
405
+ This decorator ensures that all ConfigField instances in the configuration tree are aware of their
406
+ fully qualified names by traversing the tree and calling _set_owner_name on each field.
407
+ """
408
+
409
+ def wrapper(self, *args, **kwargs):
410
+ def _commit_owner_names_recursive(instance, field_name_stack=None):
411
+ if field_name_stack is None:
412
+ field_name_stack = []
413
+
414
+ if not ConfigMeta.is_instance(instance):
415
+ return
416
+
417
+ fields = instance._fields # type: ignore
418
+ # fields is a dictionary of field_name: ConfigField
419
+ for field_name, field_info in fields.items():
420
+ if ConfigMeta.is_instance(field_info.field_type):
421
+ # extract the actual instance of the ConfigMeta class
422
+ _instance = instance.__dict__[field_name]
423
+ # The instance should hold the _commit_owner_names_across_tree
424
+ _commit_owner_names_recursive(
425
+ _instance, field_name_stack + [field_name]
426
+ )
427
+ else:
428
+ if len(field_name_stack) > 0:
429
+ for x in field_name_stack:
430
+ field_info._set_owner_name(x) # type: ignore
431
+
432
+ # Commit owner names before executing the original function
433
+ _commit_owner_names_recursive(self)
434
+
435
+ # Execute the original function
436
+ return func(self, *args, **kwargs)
437
+
438
+ return wrapper
439
+
440
+
441
+ class ConfigMeta(type):
442
+ """Metaclass implementing the configuration system's class transformation layer.
443
+
444
+ This metaclass exists to solve the fundamental problem of creating a declarative configuration
445
+ system that can automatically generate runtime behavior from field definitions. Without a
446
+ metaclass, each configuration class would need to manually implement field discovery,
447
+ validation, CLI integration, and nested object handling, leading to boilerplate code and
448
+ inconsistent behavior across the system.
449
+
450
+ Technical Implementation:
451
+
452
+ During class creation (__new__), this metaclass intercepts the class namespace and performs
453
+ several critical transformations:
454
+
455
+ 1. Field Discovery: Scans the class namespace for ConfigField instances and extracts their
456
+ metadata into a `_fields` registry. This registry becomes the source of truth for all
457
+ runtime operations including validation, CLI generation, and serialization.
458
+
459
+ 2. Method Injection: Adds the `_get_field` method to enable programmatic access to field
460
+ metadata. This method is used throughout the system by validation functions, CLI
461
+ generators, and configuration mergers.
462
+
463
+ 3. __init__ Override: Replaces the class's __init__ method with a standardized version that
464
+ handles three critical initialization phases:
465
+ - Field initialization to None (explicit defaulting happens later via apply_defaults)
466
+ - Nested config object instantiation for ConfigMeta-based field types
467
+ - Keyword argument processing for programmatic configuration
468
+
469
+ System Integration and Lifecycle:
470
+
471
+ The metaclass integrates with the broader configuration system through several key interfaces:
472
+
473
+ - populate_config_recursive: Uses the _fields registry to map external data sources
474
+ (CLI options, config files) to object attributes
475
+ - apply_defaults: Traverses the _fields registry to apply default values after population
476
+ - validate_config_meta: Uses field metadata to execute validation functions
477
+ - merge_field_values: Consults field behavior settings to determine merge strategies
478
+ - config_meta_to_dict: Converts instances back to dictionaries for serialization
479
+
480
+ Lifecycle Phases:
481
+
482
+ 1. Class Definition: Metaclass transforms the class, creating _fields registry
483
+ 2. Instance Creation: Auto-generated __init__ initializes fields and nested objects
484
+ 3. Population: External systems use _fields to populate from CLI/config files
485
+ 4. Validation: Field metadata drives validation and required field checking
486
+ 5. Default Application: Fields with None values receive their defaults
487
+ 6. Runtime Usage: Descriptor protocol provides controlled field access
488
+
489
+ Why a Metaclass:
490
+
491
+ The alternatives to a metaclass would be:
492
+ - Manual field registration in each class (error-prone, inconsistent)
493
+ - Inheritance-based approach (doesn't solve the field discovery problem)
494
+ - Decorator-based approach (requires manual application, less automatic)
495
+ - Runtime introspection (performance overhead, less reliable)
496
+
497
+ The metaclass provides automatic, consistent behavior while maintaining the declarative
498
+ syntax that makes configuration classes readable and maintainable.
499
+
500
+ Usage Pattern:
501
+ ```python
502
+ class MyConfig(metaclass=ConfigMeta):
503
+ name = ConfigField(field_type=str, required=True)
504
+ port = ConfigField(field_type=int, default=8080)
505
+ resources = ConfigField(field_type=ResourceConfig)
506
+ ```
507
+ """
508
+
509
+ @staticmethod
510
+ def is_instance(value) -> bool:
511
+ # Check for _fields attribute AND that it's a dict (not a tuple like namedtuples have)
512
+ return hasattr(value, "_fields") and isinstance(
513
+ getattr(value, "_fields", None), dict
514
+ )
515
+
516
+ def __new__(mcs, name, bases, namespace):
517
+ # Collect field metadata
518
+ fields = {}
519
+ for key, value in namespace.items():
520
+ if isinstance(value, ConfigField):
521
+ fields[key] = value
522
+
523
+ # Store fields metadata on the class
524
+ namespace["_fields"] = fields
525
+
526
+ # Inject a function to get configField from a field name
527
+ def get_field(cls, field_name: str) -> ConfigField:
528
+ return fields[field_name]
529
+
530
+ namespace["_get_field"] = get_field
531
+
532
+ # Auto-generate __init__ method;
533
+ # Override it for all classes.
534
+ def __init__(self, **kwargs):
535
+ # Initialize all fields with Nones or other values
536
+ # We initiaze with None because defaulting should be a
537
+ # constant behavior
538
+ for field_name, field_info in fields.items():
539
+ default_value = None
540
+
541
+ # Handle nested config objects
542
+ if ConfigMeta.is_instance(field_info.field_type):
543
+ # Create nested config instance with defaults
544
+ default_value = field_info.field_type()
545
+
546
+ # Set from kwargs or use default
547
+ if field_name in kwargs:
548
+ setattr(self, field_name, kwargs[field_name])
549
+ else:
550
+ setattr(self, field_name, default_value)
551
+
552
+ return super().__new__(mcs, name, bases, namespace)
553
+
554
+
555
+ def apply_defaults(config) -> None:
556
+ """
557
+ Apply default values to any fields that are still None.
558
+
559
+ Args:
560
+ config: instance of a ConfigMeta object
561
+ """
562
+ for field_name, field_info in config._fields.items():
563
+ current_value = getattr(config, field_name, None)
564
+
565
+ if current_value is None:
566
+ # The nested configs will never be set to None
567
+ # Since we always override the init function.
568
+ # The init function will always instantiate the
569
+ # sub-objects under the class
570
+ # Set default value for regular fields
571
+ setattr(config, field_name, field_info.default)
572
+ elif ConfigMeta.is_instance(field_info.field_type) and ConfigMeta.is_instance(
573
+ current_value
574
+ ):
575
+
576
+ # Apply defaults to nested config (to any sub values that might need it)
577
+ apply_defaults(current_value)
578
+ # also apply defaults to the current value if a defaults callable is provided.
579
+ # Certain top level config fields might require default setting based on the
580
+ # what the current value in the fields is set.
581
+ if field_info.default:
582
+ field_info.default(current_value)
583
+
584
+
585
+ class ConfigValidationFailedException(Exception):
586
+ def __init__(
587
+ self,
588
+ field_name: str,
589
+ field_info: ConfigField,
590
+ current_value,
591
+ message: Optional[str] = None,
592
+ ):
593
+ self.field_name = field_name
594
+ self.field_info = field_info
595
+ self.current_value = current_value
596
+ self.message = (
597
+ f"Validation failed for field {field_name} with value {current_value}"
598
+ )
599
+ if message is not None:
600
+ self.message = message
601
+
602
+ suffix = "\n\tThis configuration is set via the the following interfaces:\n\n"
603
+ suffix += "\t\t1. Config file: `%s`\n" % field_info.fully_qualified_name()
604
+ suffix += (
605
+ "\t\t2. Programatic API (Python): `%s`\n"
606
+ % field_info.fully_qualified_name()
607
+ )
608
+ if field_info.cli_meta:
609
+ suffix += "\t\t3. CLI: `%s`\n" % field_info.cli_meta.cli_option_str
610
+
611
+ self.message += suffix
612
+
613
+ super().__init__(self.message)
614
+
615
+
616
+ class RequiredFieldMissingException(ConfigValidationFailedException):
617
+ pass
618
+
619
+
620
+ class MergingNotAllowedFieldsException(ConfigValidationFailedException):
621
+ def __init__(
622
+ self,
623
+ field_name: str,
624
+ field_info: ConfigField,
625
+ current_value: Any,
626
+ override_value: Any,
627
+ ):
628
+ super().__init__(
629
+ field_name=field_name,
630
+ field_info=field_info,
631
+ current_value=current_value,
632
+ message=f"Merging not allowed for field {field_name} with value {current_value} and override value {override_value}",
633
+ )
634
+ self.override_value = override_value
635
+
636
+
637
+ def validate_required_fields(config_instance):
638
+ for field_name, field_info in config_instance._fields.items():
639
+ if field_info.required:
640
+ current_value = getattr(config_instance, field_name, None)
641
+ if current_value is None:
642
+ raise RequiredFieldMissingException(
643
+ field_name, field_info, current_value
644
+ )
645
+ if ConfigMeta.is_instance(field_info.field_type) and ConfigMeta.is_instance(
646
+ current_value
647
+ ):
648
+ validate_required_fields(current_value)
649
+ # TODO: Fix the exception handling over here.
650
+
651
+
652
+ def validate_config_meta(config_instance):
653
+ for field_name, field_info in config_instance._fields.items():
654
+ current_value = getattr(config_instance, field_name, None)
655
+
656
+ if ConfigMeta.is_instance(field_info.field_type) and ConfigMeta.is_instance(
657
+ current_value
658
+ ):
659
+ validate_config_meta(current_value)
660
+
661
+ if field_info.validation_fn:
662
+ if not field_info.validation_fn(current_value):
663
+ raise ConfigValidationFailedException(
664
+ field_name, field_info, current_value
665
+ )
666
+
667
+
668
+ def config_meta_to_dict(config_instance) -> Optional[Dict[str, Any]]:
669
+ """Convert a configuration instance to a nested dictionary.
670
+
671
+ Recursively converts ConfigMeta-based configuration instances to dictionaries,
672
+ handling nested config objects and preserving the structure.
673
+
674
+ Args:
675
+ config_instance: Instance of a ConfigMeta-based configuration class
676
+
677
+ Returns:
678
+ Nested dictionary representation of the configuration
679
+
680
+ Examples:
681
+ # Convert a config instance to dict
682
+
683
+ config_dict = to_dict(config)
684
+
685
+ # Result will be:
686
+ # {
687
+ # "name": "myapp",
688
+ # "port": 8000,
689
+ # "resources": {
690
+ # "cpu": "500m",
691
+ # "memory": "1Gi",
692
+ # "gpu": None,
693
+ # "disk": "20Gi"
694
+ # },
695
+ # "auth": None,
696
+ # ...
697
+ # }
698
+ """
699
+ if config_instance is None:
700
+ return None
701
+
702
+ # Helper to check if something is a namedtuple
703
+ def _is_namedtuple(obj):
704
+ return (
705
+ isinstance(obj, tuple)
706
+ and hasattr(obj, "_fields")
707
+ and isinstance(getattr(obj, "_fields", None), tuple)
708
+ )
709
+
710
+ # Check if this is a ConfigMeta-based class
711
+ if not ConfigMeta.is_instance(config_instance):
712
+ # If it's not a config object, return as-is
713
+ # Handle namedtuples by converting to dict
714
+ if _is_namedtuple(config_instance):
715
+ return config_instance._asdict()
716
+ return config_instance
717
+
718
+ result = {}
719
+
720
+ # Iterate through all fields defined in the class
721
+ for field_name, field_info in config_instance._fields.items():
722
+ # Get the current value
723
+ value = getattr(config_instance, field_name, None)
724
+
725
+ # Handle nested config objects recursively
726
+ if value is not None and ConfigMeta.is_instance(value):
727
+ # It's a nested config object
728
+ result[field_name] = config_meta_to_dict(value)
729
+ elif _is_namedtuple(value):
730
+ # Handle namedtuples by converting to dict
731
+ result[field_name] = value._asdict()
732
+ elif isinstance(value, list) and value:
733
+ # Handle lists that might contain config objects or namedtuples
734
+ result[field_name] = [
735
+ config_meta_to_dict(item)
736
+ if ConfigMeta.is_instance(item)
737
+ else (item._asdict() if _is_namedtuple(item) else item)
738
+ for item in value
739
+ ]
740
+ elif isinstance(value, dict) and value:
741
+ # Handle dictionaries that might contain config objects or namedtuples
742
+ result[field_name] = {
743
+ k: config_meta_to_dict(v)
744
+ if ConfigMeta.is_instance(v)
745
+ else (v._asdict() if _is_namedtuple(v) else v)
746
+ for k, v in value.items()
747
+ }
748
+ else:
749
+ # Primitive type or None
750
+ result[field_name] = value
751
+
752
+ return result
753
+
754
+
755
+ def merge_field_values(
756
+ base_value: Any, override_value: Any, field_info, behavior: str
757
+ ) -> Any:
758
+ """
759
+ Merge individual field values based on behavior.
760
+
761
+ Args:
762
+ base_value: Value from base config
763
+ override_value: Value from override config
764
+ field_info: Field metadata
765
+ behavior: FieldBehavior for this field
766
+
767
+ Returns:
768
+ Merged value
769
+ """
770
+ # Handle NOT_ALLOWED behavior
771
+ if behavior == FieldBehavior.NOT_ALLOWED:
772
+ if base_value is not None and override_value is not None:
773
+ raise MergingNotAllowedFieldsException(
774
+ field_name=field_info.name,
775
+ field_info=field_info,
776
+ current_value=base_value,
777
+ override_value=override_value,
778
+ )
779
+ if base_value is None:
780
+ return override_value
781
+ return base_value
782
+
783
+ # Handle UNION behavior (default)
784
+ if behavior == FieldBehavior.UNION:
785
+ # If override is None, use base value
786
+ if override_value is None:
787
+ return (
788
+ base_value if base_value is not None else None
789
+ ) # We will not set defaults!
790
+
791
+ # If base is None, use override value
792
+ if base_value is None:
793
+ return override_value
794
+
795
+ # Handle nested config objects
796
+ if ConfigMeta.is_instance(field_info.field_type):
797
+ if isinstance(base_value, field_info.field_type) and isinstance(
798
+ override_value, field_info.field_type
799
+ ):
800
+ # Merge nested configs recursively
801
+ merged_nested = field_info.field_type()
802
+ for (
803
+ nested_field_name,
804
+ nested_field_info,
805
+ ) in field_info.field_type._fields.items():
806
+ nested_base = getattr(base_value, nested_field_name, None)
807
+ nested_override = getattr(override_value, nested_field_name, None)
808
+ nested_behavior = getattr(
809
+ nested_field_info, "behavior", FieldBehavior.UNION
810
+ )
811
+
812
+ merged_nested_value = merge_field_values(
813
+ nested_base, nested_override, nested_field_info, nested_behavior
814
+ )
815
+ setattr(merged_nested, nested_field_name, merged_nested_value)
816
+
817
+ return merged_nested
818
+ else:
819
+ # One is not a config object, use override
820
+ return override_value
821
+
822
+ # Handle lists (union behavior merges lists)
823
+ if isinstance(base_value, list) and isinstance(override_value, list):
824
+ # Merge lists by extending
825
+ merged_list = base_value.copy()
826
+ merged_list.extend(override_value)
827
+ return merged_list
828
+
829
+ # Handle dicts (union behavior merges dicts)
830
+ if isinstance(base_value, dict) and isinstance(override_value, dict):
831
+ merged_dict = base_value.copy()
832
+ merged_dict.update(override_value)
833
+ return merged_dict
834
+
835
+ # For other types, override takes precedence
836
+ return override_value
837
+
838
+ # Default: override takes precedence
839
+ return (
840
+ override_value
841
+ if override_value is not None
842
+ else (base_value if base_value is not None else None)
843
+ )
844
+
845
+
846
+ class JsonFriendlyKeyValuePair(click.ParamType): # type: ignore
847
+ name = "KV-PAIR" # type: ignore
848
+
849
+ def convert(self, value, param, ctx):
850
+ # Parse a string of the form KEY=VALUE into a dict {KEY: VALUE}
851
+ if len(value.split("=", 1)) != 2:
852
+ self.fail(
853
+ f"Invalid format for {value}. Expected format: KEY=VALUE", param, ctx
854
+ )
855
+
856
+ key, _value = value.split("=", 1)
857
+ try:
858
+ return {key: json.loads(_value)}
859
+ except json.JSONDecodeError:
860
+ return {key: _value}
861
+ except Exception as e:
862
+ self.fail(f"Invalid value for {value}. Error: {e}", param, ctx)
863
+
864
+ def __str__(self):
865
+ return repr(self)
866
+
867
+ def __repr__(self):
868
+ return "KV-PAIR"
869
+
870
+
871
+ class CommaSeparatedList(click.ParamType): # type: ignore
872
+ name = "COMMA-SEPARATED-LIST" # type: ignore
873
+
874
+ def convert(self, value, param, ctx):
875
+ if isinstance(value, list):
876
+ return value
877
+ if isinstance(value, str):
878
+ return [item.strip() for item in value.split(",") if item.strip()]
879
+ return value
880
+
881
+ def __str__(self):
882
+ return repr(self)
883
+
884
+ def __repr__(self):
885
+ return "COMMA-SEPARATED-LIST"
886
+
887
+
888
+ class PureStringKVPair(click.ParamType): # type: ignore
889
+ """Click type for key-value pairs (KEY=VALUE)."""
890
+
891
+ name = "key=value"
892
+
893
+ def convert(self, value, param, ctx):
894
+ if isinstance(value, dict):
895
+ return value
896
+ try:
897
+ key, val = value.split("=", 1)
898
+ return {key: val}
899
+ except ValueError:
900
+ self.fail(f"'{value}' is not a valid key=value pair", param, ctx)
901
+
902
+
903
+ PureStringKVPairType = PureStringKVPair()
904
+ CommaSeparatedListType = CommaSeparatedList()
905
+ JsonFriendlyKeyValuePairType = JsonFriendlyKeyValuePair()
906
+
907
+
908
+ def populate_config_recursive(
909
+ config_instance,
910
+ config_class,
911
+ source_data,
912
+ get_source_key_fn,
913
+ get_source_value_fn,
914
+ ):
915
+ """
916
+ Recursively populate a config instance from source data.
917
+
918
+ Args:
919
+ config_instance: Config object to populate
920
+ config_class: Class of the config object
921
+ source_data: Source data (dict, CLI options, etc.)
922
+ get_source_key_fn: Function to get the source key for a field
923
+ get_source_value_fn: Function to get the value from source for a key
924
+ """
925
+
926
+ for field_name, field_info in config_class._fields.items():
927
+ # When we populate the ConfigMeta based objects, we want to do the following:
928
+ # If we find some key associated to the object inside the source data, then we populate the object
929
+ # with it. If that key corresponds to a nested config meta object then we just recusively pass down the
930
+ # value of the key from source data and populate the nested object other wise just end up setting the object
931
+ source_key = get_source_key_fn(field_name, field_info)
932
+ if source_key and source_key in source_data:
933
+ value = get_source_value_fn(source_data, source_key)
934
+ if value is not None:
935
+ # Handle nested config objects (for dict sources with nested data)
936
+ if ConfigMeta.is_instance(field_info.field_type) and isinstance(
937
+ value, dict
938
+ ):
939
+ nested_config = field_info.field_type()
940
+ populate_config_recursive(
941
+ nested_config,
942
+ field_info.field_type,
943
+ value, # For dict, use the nested dict as source
944
+ get_source_key_fn,
945
+ get_source_value_fn,
946
+ )
947
+ setattr(config_instance, field_name, nested_config)
948
+ else:
949
+ # Direct value assignment for regular fields
950
+ setattr(config_instance, field_name, value)
951
+ elif ConfigMeta.is_instance(field_info.field_type):
952
+ # It might be possible that the source key that respresents a subfield is
953
+ # is present at the root level but there is no field pertainig to the ConfigMeta
954
+ # itself. So always recurisvely check the subtree and no matter what keep instantiating
955
+ # any nested config objects.
956
+ _nested_config = field_info.field_type()
957
+ populate_config_recursive(
958
+ _nested_config,
959
+ field_info.field_type,
960
+ source_data,
961
+ get_source_key_fn,
962
+ get_source_value_fn,
963
+ )
964
+ setattr(config_instance, field_name, _nested_config)
965
+ else:
966
+ pass