Skip to content

Commit 6745bd6

Browse files
authored
feat: Introduce UnconfiguredProvider class to handle unconfigured integrations (#443)
1 parent 8063f13 commit 6745bd6

File tree

4 files changed

+221
-29
lines changed

4 files changed

+221
-29
lines changed

src/galileo/__future__/integration.py

Lines changed: 34 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,14 @@
1818
from galileo.utils.exceptions import APIException
1919

2020
if TYPE_CHECKING:
21-
from galileo.__future__.provider import AnthropicProvider, AzureProvider, BedrockProvider, OpenAIProvider, Provider
21+
from galileo.__future__.provider import (
22+
AnthropicProvider,
23+
AzureProvider,
24+
BedrockProvider,
25+
OpenAIProvider,
26+
Provider,
27+
UnconfiguredProvider,
28+
)
2229

2330
logger = logging.getLogger(__name__)
2431

@@ -355,7 +362,7 @@ def _to_provider(cls, integration_db: IntegrationDB) -> Provider:
355362
# Convenience properties for accessing configured integrations by type
356363

357364
@classmethod
358-
def _get_integration_by_name(cls, integration_name: str) -> Provider | None:
365+
def _get_integration_by_name(cls, integration_name: str) -> Provider | UnconfiguredProvider:
359366
"""
360367
Get a configured integration by name.
361368
@@ -364,26 +371,24 @@ def _get_integration_by_name(cls, integration_name: str) -> Provider | None:
364371
365372
Returns
366373
-------
367-
Provider | None: The provider if found, None otherwise.
374+
Provider | UnconfiguredProvider: The provider if found, or an UnconfiguredProvider
375+
that raises helpful errors when accessed.
368376
If multiple integrations of the same type exist,
369377
returns the selected one, or the first one if none selected.
370378
"""
371379
try:
372380
providers_list = cls.list()
373381
# Type narrowing: list() without all=True returns list[Provider]
374382
if providers_list and isinstance(providers_list[0], str):
375-
return None
383+
return UnconfiguredProvider(integration_name)
376384

377385
# Cast is safe because we checked for strings above
378386
providers = cast(list[Provider], providers_list)
379387
matching = [p for p in providers if p.name == integration_name]
380388

381389
if not matching:
382-
logger.warning(
383-
f"Integration.{integration_name}: No '{integration_name}' integration configured. "
384-
f"Create one using Integration.create_{integration_name.replace('aws_', '')}()"
385-
)
386-
return None
390+
logger.debug(f"Integration.{integration_name}: No '{integration_name}' integration configured.")
391+
return UnconfiguredProvider(integration_name)
387392

388393
# If multiple matches, prefer the selected one
389394
selected = [p for p in matching if p.is_selected]
@@ -394,16 +399,17 @@ def _get_integration_by_name(cls, integration_name: str) -> Provider | None:
394399
return matching[0]
395400
except Exception as e:
396401
logger.error(f"Integration._get_integration_by_name: failed to get {integration_name}: {e}")
397-
return None
402+
return UnconfiguredProvider(integration_name)
398403

399404
@classproperty
400-
def openai(cls) -> Provider | None:
405+
def openai(cls) -> Provider | UnconfiguredProvider:
401406
"""
402407
Get the configured OpenAI integration.
403408
404409
Returns
405410
-------
406-
OpenAIProvider | None: The OpenAI provider if configured, None otherwise.
411+
OpenAIProvider | UnconfiguredProvider: The OpenAI provider if configured,
412+
or UnconfiguredProvider that raises helpful errors when accessed.
407413
408414
Examples
409415
--------
@@ -414,13 +420,14 @@ def openai(cls) -> Provider | None:
414420
return cls._get_integration_by_name("openai")
415421

416422
@classproperty
417-
def azure(cls) -> Provider | None:
423+
def azure(cls) -> Provider | UnconfiguredProvider:
418424
"""
419425
Get the configured Azure OpenAI integration.
420426
421427
Returns
422428
-------
423-
AzureProvider | None: The Azure provider if configured, None otherwise.
429+
AzureProvider | UnconfiguredProvider: The Azure provider if configured,
430+
or UnconfiguredProvider that raises helpful errors when accessed.
424431
425432
Examples
426433
--------
@@ -431,13 +438,14 @@ def azure(cls) -> Provider | None:
431438
return cls._get_integration_by_name("azure")
432439

433440
@classproperty
434-
def bedrock(cls) -> Provider | None:
441+
def bedrock(cls) -> Provider | UnconfiguredProvider:
435442
"""
436443
Get the configured AWS Bedrock integration.
437444
438445
Returns
439446
-------
440-
BedrockProvider | None: The Bedrock provider if configured, None otherwise.
447+
BedrockProvider | UnconfiguredProvider: The Bedrock provider if configured,
448+
or UnconfiguredProvider that raises helpful errors when accessed.
441449
442450
Examples
443451
--------
@@ -448,13 +456,14 @@ def bedrock(cls) -> Provider | None:
448456
return cls._get_integration_by_name("aws_bedrock")
449457

450458
@classproperty
451-
def anthropic(cls) -> Provider | None:
459+
def anthropic(cls) -> Provider | UnconfiguredProvider:
452460
"""
453461
Get the configured Anthropic (Claude) integration.
454462
455463
Returns
456464
-------
457-
AnthropicProvider | None: The Anthropic provider if configured, None otherwise.
465+
AnthropicProvider | UnconfiguredProvider: The Anthropic provider if configured,
466+
or UnconfiguredProvider that raises helpful errors when accessed.
458467
459468
Examples
460469
--------
@@ -465,13 +474,14 @@ def anthropic(cls) -> Provider | None:
465474
return cls._get_integration_by_name("anthropic")
466475

467476
@classproperty
468-
def vertex_ai(cls) -> Provider | None:
477+
def vertex_ai(cls) -> Provider | UnconfiguredProvider:
469478
"""
470479
Get the configured Google Vertex AI integration.
471480
472481
Returns
473482
-------
474-
Provider | None: The Vertex AI provider if configured, None otherwise.
483+
Provider | UnconfiguredProvider: The Vertex AI provider if configured,
484+
or UnconfiguredProvider that raises helpful errors when accessed.
475485
476486
Examples
477487
--------
@@ -482,13 +492,14 @@ def vertex_ai(cls) -> Provider | None:
482492
return cls._get_integration_by_name("vertex_ai")
483493

484494
@classproperty
485-
def mistral(cls) -> Provider | None:
495+
def mistral(cls) -> Provider | UnconfiguredProvider:
486496
"""
487497
Get the configured Mistral AI integration.
488498
489499
Returns
490500
-------
491-
Provider | None: The Mistral provider if configured, None otherwise.
501+
Provider | UnconfiguredProvider: The Mistral provider if configured,
502+
or UnconfiguredProvider that raises helpful errors when accessed.
492503
493504
Examples
494505
--------
@@ -633,4 +644,5 @@ def create_anthropic(cls, *, token: str) -> AnthropicProvider:
633644
GenericProvider,
634645
OpenAIProvider,
635646
Provider,
647+
UnconfiguredProvider,
636648
)

src/galileo/__future__/provider.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from typing import Any
77

88
from galileo.__future__.shared.base import StateManagementMixin, SyncState
9-
from galileo.__future__.shared.exceptions import APIError, ValidationError
9+
from galileo.__future__.shared.exceptions import APIError, IntegrationNotConfiguredError, ValidationError
1010
from galileo.config import GalileoPythonConfig
1111
from galileo.resources.api.integrations import (
1212
create_or_update_integration_integrations_anthropic_put,
@@ -837,5 +837,54 @@ def _get_integration_name(self) -> IntegrationName:
837837
return self._integration_name
838838

839839

840+
class UnconfiguredProvider:
841+
"""
842+
Placeholder for integrations that are not configured.
843+
844+
This class implements the Null Object Pattern to provide helpful error messages
845+
when users attempt to access an integration that doesn't exist. Instead of
846+
returning None (which leads to cryptic AttributeError messages), this class
847+
raises IntegrationNotConfiguredError with guidance on how to fix the issue.
848+
849+
Examples
850+
--------
851+
# When Azure is not configured:
852+
>>> Integration.azure.models
853+
IntegrationNotConfiguredError: No 'azure' integration configured.
854+
Create one using Integration.create_azure() or configure it in the Galileo console.
855+
856+
# Truthiness check still works:
857+
>>> if Integration.azure:
858+
... print("configured")
859+
... else:
860+
... print("not configured")
861+
not configured
862+
"""
863+
864+
_integration_name: str
865+
866+
def __init__(self, integration_name: str) -> None:
867+
# Use object.__setattr__ to bypass our custom __setattr__
868+
object.__setattr__(self, "_integration_name", integration_name)
869+
870+
def __bool__(self) -> bool:
871+
"""Allow truthiness checks: 'if Integration.azure:' returns False."""
872+
return False
873+
874+
def __getattr__(self, name: str) -> None:
875+
"""Raise helpful error when any attribute is accessed."""
876+
raise IntegrationNotConfiguredError(self._integration_name)
877+
878+
def __setattr__(self, name: str, value: Any) -> None:
879+
"""Raise helpful error when any attribute is set."""
880+
raise IntegrationNotConfiguredError(self._integration_name)
881+
882+
def __repr__(self) -> str:
883+
return f"UnconfiguredProvider('{self._integration_name}')"
884+
885+
def __str__(self) -> str:
886+
return f"UnconfiguredProvider('{self._integration_name}')"
887+
888+
840889
# Import Model here to avoid circular imports
841890
from galileo.__future__.model import Model # noqa: E402

src/galileo/__future__/shared/exceptions.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Optional
1+
from typing import ClassVar, Optional
22

33

44
class GalileoFutureError(Exception):
@@ -68,3 +68,31 @@ def __init__(self, message: str, sync_state: Optional[str] = None, original_erro
6868
super().__init__(message)
6969
self.sync_state = sync_state
7070
self.original_error = original_error
71+
72+
73+
class IntegrationNotConfiguredError(GalileoFutureError):
74+
"""
75+
Raised when attempting to use an integration that is not configured.
76+
77+
This error provides guidance on how to create or configure the integration.
78+
"""
79+
80+
# Integrations that have SDK create methods
81+
_SUPPORTED_CREATE_METHODS: ClassVar[dict[str, str]] = {
82+
"openai": "Integration.create_openai()",
83+
"azure": "Integration.create_azure()",
84+
"aws_bedrock": "Integration.create_bedrock()",
85+
"anthropic": "Integration.create_anthropic()",
86+
}
87+
88+
def __init__(self, integration_name: str):
89+
create_method = self._SUPPORTED_CREATE_METHODS.get(integration_name)
90+
if create_method:
91+
message = (
92+
f"No '{integration_name}' integration configured.\n"
93+
f"Create one using {create_method} or configure it in the Galileo console."
94+
)
95+
else:
96+
message = f"No '{integration_name}' integration configured.\nConfigure it in the Galileo console."
97+
super().__init__(message)
98+
self.integration_name = integration_name

0 commit comments

Comments
 (0)