ob-metaflow-extensions 1.4.33__py2.py3-none-any.whl → 1.6.2__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.
Files changed (38) hide show
  1. metaflow_extensions/outerbounds/plugins/__init__.py +8 -1
  2. metaflow_extensions/outerbounds/plugins/apps/core/__init__.py +8 -2
  3. metaflow_extensions/outerbounds/plugins/apps/core/_state_machine.py +6 -6
  4. metaflow_extensions/outerbounds/plugins/apps/core/app_config.py +1 -19
  5. metaflow_extensions/outerbounds/plugins/apps/core/app_deploy_decorator.py +333 -0
  6. metaflow_extensions/outerbounds/plugins/apps/core/capsule.py +150 -79
  7. metaflow_extensions/outerbounds/plugins/apps/core/config/__init__.py +4 -1
  8. metaflow_extensions/outerbounds/plugins/apps/core/config/cli_generator.py +4 -0
  9. metaflow_extensions/outerbounds/plugins/apps/core/config/config_utils.py +103 -5
  10. metaflow_extensions/outerbounds/plugins/apps/core/config/schema_export.py +12 -1
  11. metaflow_extensions/outerbounds/plugins/apps/core/config/typed_configs.py +100 -6
  12. metaflow_extensions/outerbounds/plugins/apps/core/config/typed_init_generator.py +141 -2
  13. metaflow_extensions/outerbounds/plugins/apps/core/config/unified_config.py +74 -37
  14. metaflow_extensions/outerbounds/plugins/apps/core/config_schema.yaml +6 -6
  15. metaflow_extensions/outerbounds/plugins/apps/core/dependencies.py +2 -2
  16. metaflow_extensions/outerbounds/plugins/apps/core/deployer.py +1102 -105
  17. metaflow_extensions/outerbounds/plugins/apps/core/exceptions.py +341 -0
  18. metaflow_extensions/outerbounds/plugins/apps/core/perimeters.py +42 -6
  19. metaflow_extensions/outerbounds/plugins/aws/assume_role_decorator.py +43 -3
  20. metaflow_extensions/outerbounds/plugins/fast_bakery/baker.py +10 -1
  21. metaflow_extensions/outerbounds/plugins/optuna/__init__.py +2 -1
  22. metaflow_extensions/outerbounds/plugins/snowflake/snowflake.py +37 -7
  23. metaflow_extensions/outerbounds/plugins/snowpark/snowpark.py +18 -8
  24. metaflow_extensions/outerbounds/plugins/snowpark/snowpark_cli.py +6 -0
  25. metaflow_extensions/outerbounds/plugins/snowpark/snowpark_client.py +39 -15
  26. metaflow_extensions/outerbounds/plugins/snowpark/snowpark_decorator.py +5 -2
  27. metaflow_extensions/outerbounds/plugins/snowpark/snowpark_job.py +2 -2
  28. metaflow_extensions/outerbounds/remote_config.py +20 -7
  29. metaflow_extensions/outerbounds/toplevel/apps/__init__.py +9 -0
  30. metaflow_extensions/outerbounds/toplevel/apps/exceptions.py +11 -0
  31. metaflow_extensions/outerbounds/toplevel/global_aliases_for_metaflow_package.py +1 -1
  32. metaflow_extensions/outerbounds/toplevel/ob_internal.py +1 -1
  33. {ob_metaflow_extensions-1.4.33.dist-info → ob_metaflow_extensions-1.6.2.dist-info}/METADATA +2 -2
  34. {ob_metaflow_extensions-1.4.33.dist-info → ob_metaflow_extensions-1.6.2.dist-info}/RECORD +36 -34
  35. metaflow_extensions/outerbounds/plugins/apps/app_deploy_decorator.py +0 -146
  36. metaflow_extensions/outerbounds/plugins/apps/core/app_cli.py +0 -1200
  37. {ob_metaflow_extensions-1.4.33.dist-info → ob_metaflow_extensions-1.6.2.dist-info}/WHEEL +0 -0
  38. {ob_metaflow_extensions-1.4.33.dist-info → ob_metaflow_extensions-1.6.2.dist-info}/top_level.txt +0 -0
@@ -20,16 +20,30 @@ from ._state_machine import (
20
20
  DEPLOYMENT_READY_CONDITIONS,
21
21
  LogLine,
22
22
  )
23
+ from .exceptions import (
24
+ CapsuleApiException,
25
+ CapsuleConcurrentUpgradeException,
26
+ CapsuleCrashLoopException,
27
+ CapsuleDeletedDuringDeploymentException,
28
+ CapsuleDeploymentException,
29
+ CapsuleReadinessException,
30
+ OuterboundsBackendUnhealthyException,
31
+ OuterboundsForbiddenException,
32
+ )
33
+
34
+ STATE_REFRESH_FREQUENCY = 1 # in seconds
23
35
 
24
36
 
25
- def _format_url_string(url):
37
+ def _format_url_string(url, is_https=True):
26
38
  if url is None:
27
39
  return None
28
40
 
29
41
  if url.startswith("http://") or url.startswith("https://"):
30
42
  return url
43
+ if is_https:
44
+ return f"https://{url}"
31
45
 
32
- return f"https://{url}"
46
+ return f"http://{url}"
33
47
 
34
48
 
35
49
  class CapsuleStateMachine:
@@ -84,12 +98,12 @@ class CapsuleStateMachine:
84
98
  @property
85
99
  def out_of_cluster_url(self):
86
100
  access_info = self.current_status.get("accessInfo", {}) or {}
87
- return _format_url_string(access_info.get("outOfClusterURL", None))
101
+ return _format_url_string(access_info.get("outOfClusterURL", None), True)
88
102
 
89
103
  @property
90
104
  def in_cluster_url(self):
91
105
  access_info = self.current_status.get("accessInfo", {}) or {}
92
- return _format_url_string(access_info.get("inClusterURL", None))
106
+ return _format_url_string(access_info.get("inClusterURL", None), True)
93
107
 
94
108
  @property
95
109
  def update_in_progress(self):
@@ -328,46 +342,12 @@ class CapsuleInput:
328
342
  }
329
343
 
330
344
 
331
- class CapsuleApiException(Exception):
332
- def __init__(
333
- self,
334
- url: str,
335
- method: str,
336
- status_code: int,
337
- text: str,
338
- message: Optional[str] = None,
339
- ):
340
- self.url = url
341
- self.method = method
342
- self.status_code = status_code
343
- self.text = text
344
- self.message = message
345
-
346
- def __str__(self):
347
- return (
348
- f"CapsuleApiException: {self.url} [{self.method}]: Status Code: {self.status_code} \n\n {self.text}"
349
- + (f"\n\n {self.message}" if self.message else "")
350
- )
351
-
352
-
353
- class CapsuleDeploymentException(Exception):
354
- def __init__(
355
- self,
356
- capsule_id: str,
357
- message: str,
358
- ):
359
- self.capsule_id = capsule_id
360
- self.message = message
361
-
362
- def __str__(self):
363
- return f"CapsuleDeploymentException: [{self.capsule_id}] :: {self.message}"
364
-
365
-
366
345
  class CapsuleApi:
367
- def __init__(self, base_url: str, perimeter: str, logger_fn=None):
346
+ def __init__(self, base_url: str, perimeter: str, logger_fn=None, retry_500s=False):
368
347
  self._base_url = self._create_base_url(base_url, perimeter)
369
348
  from metaflow.metaflow_config import SERVICE_HEADERS
370
349
 
350
+ self._retry_500s = retry_500s
371
351
  self._logger_fn = logger_fn
372
352
  self._request_headers = {
373
353
  **{"Content-Type": "application/json", "Connection": "keep-alive"},
@@ -393,7 +373,23 @@ class CapsuleApi:
393
373
  logger_fn=self._logger_fn,
394
374
  **kwargs,
395
375
  )
376
+ # The CapsuleApi wraps every API call happening to the capsule
377
+ # API. We do this so that we can raise exceptions in a way that make
378
+ # it clearer to the end-user and the operator. since the safe_requests_wrapper
379
+ # can already retry 5xx errors too we should ensure that any time we hit max
380
+ # retries or if we hit 5xx without retries, we should raise a "special" exception
381
+ # and not the CapsuleApiException to notify the operator that the
382
+ # backend is not working right RN and thier application crashes. We can lift the
383
+ # exception to top level to make it importable so operators can deal with that condition
384
+ # how they like.
396
385
  except MaximumRetriesExceeded as e:
386
+ if e.status_code >= 500:
387
+ raise OuterboundsBackendUnhealthyException(
388
+ e.url,
389
+ e.method,
390
+ e.status_code,
391
+ e.text,
392
+ )
397
393
  raise CapsuleApiException(
398
394
  e.url,
399
395
  e.method,
@@ -401,7 +397,41 @@ class CapsuleApi:
401
397
  e.text,
402
398
  message=f"Maximum retries exceeded for {e.url} [{e.method}]",
403
399
  )
404
- if response.status_code >= 400:
400
+ except requests.exceptions.ConnectionError as e:
401
+ # Network connectivity issues after retries exhausted
402
+ raise OuterboundsBackendUnhealthyException(
403
+ url=args[0] if args else "unknown",
404
+ method=method_func.__name__,
405
+ message=(
406
+ f"Unable to reach Outerbounds backend at {args[0] if args else 'unknown'}. "
407
+ "This could be due to network connectivity issues, DNS resolution failures, "
408
+ "or the service being temporarily unavailable. "
409
+ "Please check your network connection and retry. "
410
+ "If the issue persists, contact Outerbounds support."
411
+ ),
412
+ ) from e
413
+
414
+ if response.status_code >= 500:
415
+ raise OuterboundsBackendUnhealthyException(
416
+ args[0],
417
+ method_func.__name__,
418
+ response.status_code,
419
+ response.text,
420
+ message=(
421
+ f"Outerbounds backend returned an error (HTTP {response.status_code}). "
422
+ "This is a server-side issue, not a problem with your configuration. "
423
+ "Please retry your request. If the issue persists, contact Outerbounds support."
424
+ ),
425
+ )
426
+
427
+ elif response.status_code == 403:
428
+ raise OuterboundsForbiddenException(
429
+ args[0],
430
+ method_func.__name__,
431
+ response.text,
432
+ )
433
+
434
+ elif response.status_code >= 400:
405
435
  raise CapsuleApiException(
406
436
  args[0],
407
437
  method_func.__name__,
@@ -410,12 +440,39 @@ class CapsuleApi:
410
440
  )
411
441
  return response
412
442
 
443
+ def _retry_parameters(
444
+ self,
445
+ status_codes,
446
+ retries,
447
+ ):
448
+ """
449
+ All functions calling the wrapped_api_caller use this function
450
+ set the number of retries for the apis calls. It sets status codes
451
+ that are allowed to N retries (total including connection retries).
452
+ If no status codes are passed we should still always pass connnection
453
+ retries > 0 since DNS can be flaky in-frequently and we dont want to
454
+ trip up there.
455
+ """
456
+ kwargs = {}
457
+ if self._retry_500s:
458
+ kwargs = dict(
459
+ retryable_status_codes=status_codes
460
+ + [500, 502, 503, 504], # todo : verify me
461
+ conn_error_retries=max(
462
+ 3, retries
463
+ ), # connection retries + any other retries.
464
+ )
465
+ else:
466
+ kwargs = dict(
467
+ retryable_status_codes=status_codes,
468
+ conn_error_retries=retries, # connection retries + any other retries.
469
+ )
470
+ return kwargs
471
+
413
472
  def create(self, capsule_input: dict):
414
473
  _data = json.dumps(capsule_input)
415
474
  response = self._wrapped_api_caller(
416
- requests.post,
417
- self._base_url,
418
- data=_data,
475
+ requests.post, self._base_url, data=_data, **self._retry_parameters([], 3)
419
476
  )
420
477
  try:
421
478
  return response.json()
@@ -431,10 +488,7 @@ class CapsuleApi:
431
488
  def get(self, capsule_id: str) -> Dict[str, Any]:
432
489
  _url = os.path.join(self._base_url, capsule_id)
433
490
  response = self._wrapped_api_caller(
434
- requests.get,
435
- _url,
436
- retryable_status_codes=[409, 404], # todo : verify me
437
- conn_error_retries=3,
491
+ requests.get, _url, **self._retry_parameters([409, 404], 3)
438
492
  )
439
493
  try:
440
494
  return response.json()
@@ -451,10 +505,7 @@ class CapsuleApi:
451
505
  def get_by_name(self, name: str, most_recent_only: bool = True):
452
506
  _url = os.path.join(self._base_url, f"?displayName={name}")
453
507
  response = self._wrapped_api_caller(
454
- requests.get,
455
- _url,
456
- retryable_status_codes=[409], # todo : verify me
457
- conn_error_retries=3,
508
+ requests.get, _url, **self._retry_parameters([409], 3)
458
509
  )
459
510
  try:
460
511
  if most_recent_only:
@@ -478,10 +529,7 @@ class CapsuleApi:
478
529
 
479
530
  def list(self):
480
531
  response = self._wrapped_api_caller(
481
- requests.get,
482
- self._base_url,
483
- retryable_status_codes=[409], # todo : verify me
484
- conn_error_retries=3,
532
+ requests.get, self._base_url, **self._retry_parameters([409], 3)
485
533
  )
486
534
  try:
487
535
  response_json = response.json()
@@ -506,9 +554,7 @@ class CapsuleApi:
506
554
  def delete(self, capsule_id: str):
507
555
  _url = os.path.join(self._base_url, capsule_id)
508
556
  response = self._wrapped_api_caller(
509
- requests.delete,
510
- _url,
511
- retryable_status_codes=[409], # todo : verify me
557
+ requests.delete, _url, **self._retry_parameters([409], 3)
512
558
  )
513
559
  if response.status_code >= 400:
514
560
  raise CapsuleApiException(
@@ -527,11 +573,10 @@ class CapsuleApi:
527
573
  response = self._wrapped_api_caller(
528
574
  requests.get,
529
575
  _url,
530
- retryable_status_codes=[409, 404], # todo : verify me
531
576
  # Adding 404s because sometimes we might even end up getting 404s if
532
577
  # the backend cache is not updated yet. So on consistent 404s we should
533
578
  # just crash out.
534
- conn_error_retries=3,
579
+ **self._retry_parameters([409, 404], 3),
535
580
  )
536
581
  try:
537
582
  return response.json().get("workers", []) or []
@@ -552,10 +597,7 @@ class CapsuleApi:
552
597
  if previous:
553
598
  options = {"previous": True}
554
599
  response = self._wrapped_api_caller(
555
- requests.get,
556
- _url,
557
- retryable_status_codes=[409], # todo : verify me
558
- params=options,
600
+ requests.get, _url, params=options, **self._retry_parameters([409], 3)
559
601
  )
560
602
  try:
561
603
  return response.json().get("logs", []) or []
@@ -655,6 +697,10 @@ class CapsuleDeployer:
655
697
  base_url,
656
698
  app_config.get_state("perimeter"),
657
699
  logger_fn=logger_fn or partial(print, file=sys.stderr),
700
+ retry_500s=True
701
+ # retry for 5xx because during the capsule deployer might even be used
702
+ # programmatically so any intermittent breakage shouldnt break the overall
703
+ # control flow unless the breakage is severe (maybe over 20s of complete outage)
658
704
  )
659
705
  self._create_timeout = create_timeout
660
706
  self._logger_fn = logger_fn
@@ -666,7 +712,7 @@ class CapsuleDeployer:
666
712
  @property
667
713
  def url(self):
668
714
  return _format_url_string(
669
- ({} or self._capsule_deploy_response).get("outOfClusterUrl", None)
715
+ ({} or self._capsule_deploy_response).get("outOfClusterUrl", None), True
670
716
  )
671
717
 
672
718
  @property
@@ -723,9 +769,13 @@ class CapsuleDeployer:
723
769
  output that they desire.
724
770
  """
725
771
  if capsule_response.get("version", None) != current_deployment_instance_version:
726
- raise CapsuleDeploymentException(
772
+ metadata = capsule_response.get("metadata", {}) or {}
773
+ raise CapsuleConcurrentUpgradeException(
727
774
  self.identifier, # type: ignore
728
- f"A capsule upgrade was triggered outside current deployment instance. Current deployment version was discarded. Current deployment version: {current_deployment_instance_version} and new version: {capsule_response.get('version', None)}",
775
+ expected_version=current_deployment_instance_version,
776
+ actual_version=capsule_response.get("version", None),
777
+ modified_by=metadata.get("lastModifiedBy"),
778
+ modified_at=metadata.get("lastModifiedAt"),
729
779
  )
730
780
 
731
781
  def _update_capsule_and_worker_sm(
@@ -734,7 +784,26 @@ class CapsuleDeployer:
734
784
  workers_sm: "CapsuleWorkersStateMachine",
735
785
  logger: Callable[[str], None],
736
786
  ) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]:
737
- capsule_response = self.get()
787
+ try:
788
+ capsule_response = self.get()
789
+ except CapsuleApiException as e:
790
+ # At this point when the code is executing
791
+ # the CapsuleDeployer would already have created
792
+ # the capsule since this function is called within
793
+ # wait_for_terminal_state. Because of that if there
794
+ # is now a 404 then it means someone deleted the
795
+ # deployment WHILE this deployment instance is running
796
+ # We shoud notify the user that something funky has
797
+ # happened over here. We need to do this since Apps can
798
+ # now be programmatically created / deleted, we need to
799
+ # ensure that if some-one has done something concurrent-unsafe
800
+ # (foo deleting bar's deployment while bar is deploying)
801
+ # then for that circumstance we should raise an exception here
802
+ # that something funky has happened. Otherwise if the
803
+ # CapsuleApiException leaks out then it should be fine.
804
+ if e.status_code == 404:
805
+ raise CapsuleDeletedDuringDeploymentException(self.identifier) from e
806
+ raise
738
807
  capsule_sm.add_status(capsule_response.get("status", {})) # type: ignore
739
808
 
740
809
  # We need to check if someone has not upgraded the capsule under the hood and
@@ -771,8 +840,10 @@ class CapsuleDeployer:
771
840
  """returns True if the worker is crashlooping, False otherwise"""
772
841
  logger = self._logger_fn or partial(print, file=sys.stderr)
773
842
  for i in range(self._readiness_wait_time):
774
- time.sleep(1)
775
- self._update_capsule_and_worker_sm(capsule_sm, workers_sm, logger)
843
+ time.sleep(STATE_REFRESH_FREQUENCY)
844
+ self._update_capsule_and_worker_sm(
845
+ capsule_sm, workers_sm, logger
846
+ ) # [2 API calls]
776
847
  if workers_sm.is_crashlooping:
777
848
  return True
778
849
  return False
@@ -827,8 +898,8 @@ class CapsuleDeployer:
827
898
  # If we reach a teminal condition like described in `DEPLOYMENT_READY_CONDITIONS`, then
828
899
  # we will further check for readiness conditions.
829
900
  for i in range(self._create_timeout):
830
- time.sleep(1)
831
- capsule_response, _ = self._update_capsule_and_worker_sm(
901
+ time.sleep(STATE_REFRESH_FREQUENCY)
902
+ capsule_response, _ = self._update_capsule_and_worker_sm( # [2 API calls]
832
903
  state_machine, workers_state_machine, logger
833
904
  )
834
905
  # Deployment readiness checks will determine what is the terminal state
@@ -901,9 +972,10 @@ class CapsuleDeployer:
901
972
  + ["\t" + l["message"] for l in logs]
902
973
  )
903
974
  )
904
- raise CapsuleDeploymentException(
975
+ raise CapsuleCrashLoopException(
905
976
  self.identifier,
906
- f"Worker ID ({worker_id}) is crashlooping. Please check the logs for more information.",
977
+ worker_id=worker_id,
978
+ logs=logs,
907
979
  )
908
980
 
909
981
  if state_machine.ready_to_serve_traffic:
@@ -942,14 +1014,13 @@ class CapsuleDeployer:
942
1014
  and not _is_async_readiness
943
1015
  and not self.status.ready_to_serve_traffic
944
1016
  ):
945
- raise CapsuleDeploymentException(
1017
+ raise CapsuleReadinessException(
946
1018
  self.identifier,
947
- f"Capsule {self.identifier} failed to be ready to serve traffic",
948
1019
  )
949
-
1020
+ auth_type = self._app_config.get_state("auth", {}).get("type", AuthType.default)
950
1021
  return dict(
951
1022
  id=self.identifier,
952
- auth_type=self.capsule_type,
1023
+ auth_type=auth_type,
953
1024
  public_url=self.url,
954
1025
  available_replicas=self.status.available_replicas,
955
1026
  name=self.name,
@@ -1,4 +1,4 @@
1
- from .unified_config import CoreConfig
1
+ from .unified_config import CoreConfig, PackagedCode, BakedImage, AuthType
2
2
  from .cli_generator import auto_cli_options
3
3
  from .config_utils import (
4
4
  PureStringKVPairType,
@@ -7,6 +7,9 @@ from .config_utils import (
7
7
  MergingNotAllowedFieldsException,
8
8
  ConfigValidationFailedException,
9
9
  RequiredFieldMissingException,
10
+ ConfigFieldContext,
11
+ ConfigField,
12
+ FieldBehavior,
10
13
  )
11
14
  from . import schema_export
12
15
  from .typed_configs import TypedCoreConfig, TypedDict
@@ -54,6 +54,10 @@ class CLIGenerator:
54
54
  def _options_from_cfg_cls(_config_class):
55
55
  options = []
56
56
  for field_name, field_info in _config_class._fields.items():
57
+ # Skip fields not available in CLI context
58
+ if not field_info.is_available_in_cli():
59
+ continue
60
+
57
61
  if ConfigMeta.is_instance(field_info.field_type):
58
62
  _subfield_options = _options_from_cfg_cls(field_info.field_type)
59
63
  options.extend(_subfield_options)
@@ -62,6 +62,67 @@ class FieldBehavior:
62
62
  NOT_ALLOWED = "not_allowed"
63
63
 
64
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
+
65
126
  class CLIOption:
66
127
  """Metadata container for automatic CLI option generation from configuration fields.
67
128
 
@@ -225,6 +286,9 @@ class ConfigField:
225
286
  Optional function to validate field values.
226
287
  is_experimental : bool, optional
227
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.
228
292
  """
229
293
 
230
294
  def __init__(
@@ -240,6 +304,7 @@ class ConfigField:
240
304
  validation_fn: Optional[Callable] = None,
241
305
  is_experimental=False, # This property is for bookkeeping purposes and for export in schema.
242
306
  parsing_fn: Optional[Callable] = None,
307
+ available_in: str = ConfigFieldContext.ALL,
243
308
  ):
244
309
  if behavior == FieldBehavior.NOT_ALLOWED and ConfigMeta.is_instance(field_type):
245
310
  raise ValueError(
@@ -264,6 +329,7 @@ class ConfigField:
264
329
  self.validation_fn = validation_fn
265
330
  self.is_experimental = is_experimental
266
331
  self.parsing_fn = parsing_fn
332
+ self.available_in = available_in
267
333
  self._qual_name_stack = []
268
334
 
269
335
  # This function allows config fields to be made aware of the
@@ -280,6 +346,17 @@ class ConfigField:
280
346
  def fully_qualified_name(self):
281
347
  return ".".join(self._qual_name_stack + [self.name])
282
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
+
283
360
  def __set_name__(self, owner, name):
284
361
  self.name = name
285
362
 
@@ -431,7 +508,10 @@ class ConfigMeta(type):
431
508
 
432
509
  @staticmethod
433
510
  def is_instance(value) -> bool:
434
- return hasattr(value, "_fields")
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
+ )
435
515
 
436
516
  def __new__(mcs, name, bases, namespace):
437
517
  # Collect field metadata
@@ -619,9 +699,20 @@ def config_meta_to_dict(config_instance) -> Optional[Dict[str, Any]]:
619
699
  if config_instance is None:
620
700
  return None
621
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
+
622
710
  # Check if this is a ConfigMeta-based class
623
711
  if not ConfigMeta.is_instance(config_instance):
624
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()
625
716
  return config_instance
626
717
 
627
718
  result = {}
@@ -635,16 +726,23 @@ def config_meta_to_dict(config_instance) -> Optional[Dict[str, Any]]:
635
726
  if value is not None and ConfigMeta.is_instance(value):
636
727
  # It's a nested config object
637
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()
638
732
  elif isinstance(value, list) and value:
639
- # Handle lists that might contain config objects
733
+ # Handle lists that might contain config objects or namedtuples
640
734
  result[field_name] = [
641
- config_meta_to_dict(item) if ConfigMeta.is_instance(item) else item
735
+ config_meta_to_dict(item)
736
+ if ConfigMeta.is_instance(item)
737
+ else (item._asdict() if _is_namedtuple(item) else item)
642
738
  for item in value
643
739
  ]
644
740
  elif isinstance(value, dict) and value:
645
- # Handle dictionaries that might contain config objects
741
+ # Handle dictionaries that might contain config objects or namedtuples
646
742
  result[field_name] = {
647
- k: config_meta_to_dict(v) if ConfigMeta.is_instance(v) else v
743
+ k: config_meta_to_dict(v)
744
+ if ConfigMeta.is_instance(v)
745
+ else (v._asdict() if _is_namedtuple(v) else v)
648
746
  for k, v in value.items()
649
747
  }
650
748
  else:
@@ -165,7 +165,11 @@ def export_schema(
165
165
 
166
166
  # Private helper functions
167
167
  def _generate_openapi_schema(cls) -> Dict[str, Any]:
168
- """Generate OpenAPI schema for a configuration class."""
168
+ """Generate OpenAPI schema for a configuration class.
169
+
170
+ Note: Schema is intended for CLI/config file usage, so PROGRAMMATIC-only
171
+ fields are excluded.
172
+ """
169
173
  # Clean up class docstring for better YAML formatting
170
174
  description = f"{cls.__name__} configuration"
171
175
  get_description = getattr(cls, "SCHEMA_DOC", None)
@@ -193,6 +197,13 @@ def _generate_openapi_schema(cls) -> Dict[str, Any]:
193
197
  if field_name.startswith("_"):
194
198
  continue
195
199
 
200
+ # Skip PROGRAMMATIC-only fields - schema is for CLI/config file usage
201
+ if (
202
+ hasattr(field_info, "is_available_in_cli")
203
+ and not field_info.is_available_in_cli()
204
+ ):
205
+ continue
206
+
196
207
  field_schema = _get_field_schema(field_info)
197
208
  schema["properties"][field_name] = field_schema
198
209