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.
- metaflow_extensions/outerbounds/plugins/__init__.py +8 -1
- metaflow_extensions/outerbounds/plugins/apps/core/__init__.py +8 -2
- metaflow_extensions/outerbounds/plugins/apps/core/_state_machine.py +6 -6
- metaflow_extensions/outerbounds/plugins/apps/core/app_config.py +1 -19
- metaflow_extensions/outerbounds/plugins/apps/core/app_deploy_decorator.py +333 -0
- metaflow_extensions/outerbounds/plugins/apps/core/capsule.py +150 -79
- metaflow_extensions/outerbounds/plugins/apps/core/config/__init__.py +4 -1
- metaflow_extensions/outerbounds/plugins/apps/core/config/cli_generator.py +4 -0
- metaflow_extensions/outerbounds/plugins/apps/core/config/config_utils.py +103 -5
- metaflow_extensions/outerbounds/plugins/apps/core/config/schema_export.py +12 -1
- metaflow_extensions/outerbounds/plugins/apps/core/config/typed_configs.py +100 -6
- metaflow_extensions/outerbounds/plugins/apps/core/config/typed_init_generator.py +141 -2
- metaflow_extensions/outerbounds/plugins/apps/core/config/unified_config.py +74 -37
- metaflow_extensions/outerbounds/plugins/apps/core/config_schema.yaml +6 -6
- metaflow_extensions/outerbounds/plugins/apps/core/dependencies.py +2 -2
- metaflow_extensions/outerbounds/plugins/apps/core/deployer.py +1102 -105
- metaflow_extensions/outerbounds/plugins/apps/core/exceptions.py +341 -0
- metaflow_extensions/outerbounds/plugins/apps/core/perimeters.py +42 -6
- metaflow_extensions/outerbounds/plugins/aws/assume_role_decorator.py +43 -3
- metaflow_extensions/outerbounds/plugins/fast_bakery/baker.py +10 -1
- metaflow_extensions/outerbounds/plugins/optuna/__init__.py +2 -1
- metaflow_extensions/outerbounds/plugins/snowflake/snowflake.py +37 -7
- metaflow_extensions/outerbounds/plugins/snowpark/snowpark.py +18 -8
- metaflow_extensions/outerbounds/plugins/snowpark/snowpark_cli.py +6 -0
- metaflow_extensions/outerbounds/plugins/snowpark/snowpark_client.py +39 -15
- metaflow_extensions/outerbounds/plugins/snowpark/snowpark_decorator.py +5 -2
- metaflow_extensions/outerbounds/plugins/snowpark/snowpark_job.py +2 -2
- metaflow_extensions/outerbounds/remote_config.py +20 -7
- metaflow_extensions/outerbounds/toplevel/apps/__init__.py +9 -0
- metaflow_extensions/outerbounds/toplevel/apps/exceptions.py +11 -0
- metaflow_extensions/outerbounds/toplevel/global_aliases_for_metaflow_package.py +1 -1
- metaflow_extensions/outerbounds/toplevel/ob_internal.py +1 -1
- {ob_metaflow_extensions-1.4.33.dist-info → ob_metaflow_extensions-1.6.2.dist-info}/METADATA +2 -2
- {ob_metaflow_extensions-1.4.33.dist-info → ob_metaflow_extensions-1.6.2.dist-info}/RECORD +36 -34
- metaflow_extensions/outerbounds/plugins/apps/app_deploy_decorator.py +0 -146
- metaflow_extensions/outerbounds/plugins/apps/core/app_cli.py +0 -1200
- {ob_metaflow_extensions-1.4.33.dist-info → ob_metaflow_extensions-1.6.2.dist-info}/WHEEL +0 -0
- {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"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
772
|
+
metadata = capsule_response.get("metadata", {}) or {}
|
|
773
|
+
raise CapsuleConcurrentUpgradeException(
|
|
727
774
|
self.identifier, # type: ignore
|
|
728
|
-
|
|
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
|
-
|
|
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(
|
|
775
|
-
self._update_capsule_and_worker_sm(
|
|
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(
|
|
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
|
|
975
|
+
raise CapsuleCrashLoopException(
|
|
905
976
|
self.identifier,
|
|
906
|
-
|
|
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
|
|
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=
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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
|
|