airbyte-agent-airtable 0.1.5__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 (58) hide show
  1. airbyte_agent_airtable/__init__.py +81 -0
  2. airbyte_agent_airtable/_vendored/__init__.py +1 -0
  3. airbyte_agent_airtable/_vendored/connector_sdk/__init__.py +82 -0
  4. airbyte_agent_airtable/_vendored/connector_sdk/auth_strategies.py +1171 -0
  5. airbyte_agent_airtable/_vendored/connector_sdk/auth_template.py +135 -0
  6. airbyte_agent_airtable/_vendored/connector_sdk/cloud_utils/__init__.py +5 -0
  7. airbyte_agent_airtable/_vendored/connector_sdk/cloud_utils/client.py +338 -0
  8. airbyte_agent_airtable/_vendored/connector_sdk/connector_model_loader.py +1121 -0
  9. airbyte_agent_airtable/_vendored/connector_sdk/constants.py +78 -0
  10. airbyte_agent_airtable/_vendored/connector_sdk/exceptions.py +23 -0
  11. airbyte_agent_airtable/_vendored/connector_sdk/executor/__init__.py +31 -0
  12. airbyte_agent_airtable/_vendored/connector_sdk/executor/hosted_executor.py +230 -0
  13. airbyte_agent_airtable/_vendored/connector_sdk/executor/local_executor.py +1848 -0
  14. airbyte_agent_airtable/_vendored/connector_sdk/executor/models.py +202 -0
  15. airbyte_agent_airtable/_vendored/connector_sdk/extensions.py +693 -0
  16. airbyte_agent_airtable/_vendored/connector_sdk/http/__init__.py +37 -0
  17. airbyte_agent_airtable/_vendored/connector_sdk/http/adapters/__init__.py +9 -0
  18. airbyte_agent_airtable/_vendored/connector_sdk/http/adapters/httpx_adapter.py +260 -0
  19. airbyte_agent_airtable/_vendored/connector_sdk/http/config.py +98 -0
  20. airbyte_agent_airtable/_vendored/connector_sdk/http/exceptions.py +119 -0
  21. airbyte_agent_airtable/_vendored/connector_sdk/http/protocols.py +114 -0
  22. airbyte_agent_airtable/_vendored/connector_sdk/http/response.py +104 -0
  23. airbyte_agent_airtable/_vendored/connector_sdk/http_client.py +693 -0
  24. airbyte_agent_airtable/_vendored/connector_sdk/introspection.py +481 -0
  25. airbyte_agent_airtable/_vendored/connector_sdk/logging/__init__.py +11 -0
  26. airbyte_agent_airtable/_vendored/connector_sdk/logging/logger.py +273 -0
  27. airbyte_agent_airtable/_vendored/connector_sdk/logging/types.py +93 -0
  28. airbyte_agent_airtable/_vendored/connector_sdk/observability/__init__.py +11 -0
  29. airbyte_agent_airtable/_vendored/connector_sdk/observability/config.py +179 -0
  30. airbyte_agent_airtable/_vendored/connector_sdk/observability/models.py +19 -0
  31. airbyte_agent_airtable/_vendored/connector_sdk/observability/redactor.py +81 -0
  32. airbyte_agent_airtable/_vendored/connector_sdk/observability/session.py +103 -0
  33. airbyte_agent_airtable/_vendored/connector_sdk/performance/__init__.py +6 -0
  34. airbyte_agent_airtable/_vendored/connector_sdk/performance/instrumentation.py +57 -0
  35. airbyte_agent_airtable/_vendored/connector_sdk/performance/metrics.py +93 -0
  36. airbyte_agent_airtable/_vendored/connector_sdk/schema/__init__.py +75 -0
  37. airbyte_agent_airtable/_vendored/connector_sdk/schema/base.py +212 -0
  38. airbyte_agent_airtable/_vendored/connector_sdk/schema/components.py +244 -0
  39. airbyte_agent_airtable/_vendored/connector_sdk/schema/connector.py +120 -0
  40. airbyte_agent_airtable/_vendored/connector_sdk/schema/extensions.py +301 -0
  41. airbyte_agent_airtable/_vendored/connector_sdk/schema/operations.py +156 -0
  42. airbyte_agent_airtable/_vendored/connector_sdk/schema/security.py +241 -0
  43. airbyte_agent_airtable/_vendored/connector_sdk/secrets.py +182 -0
  44. airbyte_agent_airtable/_vendored/connector_sdk/telemetry/__init__.py +10 -0
  45. airbyte_agent_airtable/_vendored/connector_sdk/telemetry/config.py +32 -0
  46. airbyte_agent_airtable/_vendored/connector_sdk/telemetry/events.py +59 -0
  47. airbyte_agent_airtable/_vendored/connector_sdk/telemetry/tracker.py +155 -0
  48. airbyte_agent_airtable/_vendored/connector_sdk/types.py +274 -0
  49. airbyte_agent_airtable/_vendored/connector_sdk/utils.py +127 -0
  50. airbyte_agent_airtable/_vendored/connector_sdk/validation.py +997 -0
  51. airbyte_agent_airtable/_vendored/connector_sdk/validation_replication.py +970 -0
  52. airbyte_agent_airtable/connector.py +834 -0
  53. airbyte_agent_airtable/connector_model.py +365 -0
  54. airbyte_agent_airtable/models.py +219 -0
  55. airbyte_agent_airtable/types.py +367 -0
  56. airbyte_agent_airtable-0.1.5.dist-info/METADATA +140 -0
  57. airbyte_agent_airtable-0.1.5.dist-info/RECORD +58 -0
  58. airbyte_agent_airtable-0.1.5.dist-info/WHEEL +4 -0
@@ -0,0 +1,103 @@
1
+ """Shared session context for both logging and telemetry."""
2
+
3
+ import logging
4
+ import uuid
5
+ from datetime import UTC, datetime
6
+ from typing import Any, Dict
7
+
8
+ from .config import SDKConfig, load_config
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ # Cache the config at module level to avoid repeated reads
13
+ _cached_config: SDKConfig | None = None
14
+
15
+
16
+ def _get_config() -> SDKConfig:
17
+ """Get cached SDK config or load from file."""
18
+ global _cached_config
19
+ if _cached_config is None:
20
+ _cached_config = load_config()
21
+ return _cached_config
22
+
23
+
24
+ def _clear_config_cache() -> None:
25
+ """Clear the cached config. Used for testing."""
26
+ global _cached_config
27
+ _cached_config = None
28
+
29
+
30
+ def get_persistent_user_id() -> str:
31
+ """
32
+ Get the persistent anonymous user ID.
33
+
34
+ Now reads from ~/.airbyte/connector-sdk/config.yaml
35
+
36
+ Returns:
37
+ An anonymous UUID string that uniquely identifies this user across sessions.
38
+ """
39
+ return _get_config().user_id
40
+
41
+
42
+ def get_public_ip() -> str | None:
43
+ """
44
+ Fetch the public IP address of the user.
45
+
46
+ Returns None if unable to fetch (network issues, etc).
47
+ Uses httpx for a robust HTTP request to a public IP service.
48
+ """
49
+ try:
50
+ # NOTE: Import here intentionally - this is a non-critical network call
51
+ # that may fail. Importing at module level would make httpx a hard dependency.
52
+ import httpx
53
+
54
+ # Use a short timeout to avoid blocking
55
+ with httpx.Client(timeout=2.0) as client:
56
+ response = client.get("https://api.ipify.org?format=text")
57
+ response.raise_for_status()
58
+ return response.text.strip()
59
+ except Exception:
60
+ # Never fail - just return None
61
+ return None
62
+
63
+
64
+ def get_is_internal_user() -> bool:
65
+ """
66
+ Check if the current user is an internal Airbyte user.
67
+
68
+ Now reads from ~/.airbyte/connector-sdk/config.yaml
69
+ Environment variable AIRBYTE_INTERNAL_USER can override.
70
+
71
+ Returns False if not set or on any error.
72
+ """
73
+ return _get_config().is_internal_user
74
+
75
+
76
+ class ObservabilitySession:
77
+ """Shared session context for both logging and telemetry."""
78
+
79
+ def __init__(
80
+ self,
81
+ connector_name: str,
82
+ connector_version: str | None = None,
83
+ execution_context: str = "direct",
84
+ session_id: str | None = None,
85
+ ):
86
+ self.session_id = session_id or str(uuid.uuid4())
87
+ self.user_id = get_persistent_user_id()
88
+ self.connector_name = connector_name
89
+ self.connector_version = connector_version
90
+ self.execution_context = execution_context
91
+ self.started_at = datetime.now(UTC)
92
+ self.operation_count = 0
93
+ self.metadata: Dict[str, Any] = {}
94
+ self.public_ip = get_public_ip()
95
+ self.is_internal_user = get_is_internal_user()
96
+
97
+ def increment_operations(self):
98
+ """Increment the operation counter."""
99
+ self.operation_count += 1
100
+
101
+ def duration_seconds(self) -> float:
102
+ """Calculate session duration in seconds."""
103
+ return (datetime.now(UTC) - self.started_at).total_seconds()
@@ -0,0 +1,6 @@
1
+ """Performance monitoring and instrumentation for async operations."""
2
+
3
+ from .instrumentation import instrument
4
+ from .metrics import PerformanceMonitor
5
+
6
+ __all__ = ["instrument", "PerformanceMonitor"]
@@ -0,0 +1,57 @@
1
+ """Performance instrumentation decorator for async functions."""
2
+
3
+ import functools
4
+ import logging
5
+ import time
6
+ from typing import Any, Callable, TypeVar
7
+
8
+ # Type variable for generic function decoration
9
+ F = TypeVar("F", bound=Callable[..., Any])
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ def instrument(metric_name: str) -> Callable[[F], F]:
15
+ """Decorator to instrument async functions with performance tracking.
16
+
17
+ Args:
18
+ metric_name: Name of the metric to track
19
+
20
+ Returns:
21
+ Decorator function
22
+
23
+ Example:
24
+ @instrument("stripe.customer.list")
25
+ async def list_customers():
26
+ ...
27
+ """
28
+
29
+ def decorator(func: F) -> F:
30
+ @functools.wraps(func)
31
+ async def wrapper(*args, **kwargs):
32
+ start_time = time.time()
33
+ success = True
34
+ error = None
35
+
36
+ try:
37
+ result = await func(*args, **kwargs)
38
+ return result
39
+
40
+ except Exception as e:
41
+ success = False
42
+ error = e
43
+ raise
44
+
45
+ finally:
46
+ duration = time.time() - start_time
47
+ duration_ms = duration * 1000
48
+
49
+ # Log performance metrics
50
+ if success:
51
+ logger.debug(f"[{metric_name}] completed in {duration_ms:.2f}ms")
52
+ else:
53
+ logger.warning(f"[{metric_name}] failed after {duration_ms:.2f}ms: {error}")
54
+
55
+ return wrapper # type: ignore
56
+
57
+ return decorator
@@ -0,0 +1,93 @@
1
+ """Performance metrics tracking."""
2
+
3
+ import time
4
+ from contextlib import asynccontextmanager
5
+ from typing import Dict
6
+
7
+
8
+ class PerformanceMonitor:
9
+ """Monitor and track performance metrics for operations."""
10
+
11
+ def __init__(self):
12
+ """Initialize performance monitor."""
13
+ self._metrics: Dict[str, Dict[str, float]] = {}
14
+
15
+ def record(self, metric_name: str, duration: float):
16
+ """Record a metric.
17
+
18
+ Args:
19
+ metric_name: Name of the metric
20
+ duration: Duration in seconds
21
+ """
22
+ if metric_name not in self._metrics:
23
+ self._metrics[metric_name] = {
24
+ "count": 0,
25
+ "total": 0.0,
26
+ "min": float("inf"),
27
+ "max": 0.0,
28
+ }
29
+
30
+ metrics = self._metrics[metric_name]
31
+ metrics["count"] += 1
32
+ metrics["total"] += duration
33
+ metrics["min"] = min(metrics["min"], duration)
34
+ metrics["max"] = max(metrics["max"], duration)
35
+
36
+ def get_stats(self, metric_name: str) -> Dict[str, float] | None:
37
+ """Get statistics for a metric.
38
+
39
+ Args:
40
+ metric_name: Name of the metric
41
+
42
+ Returns:
43
+ Dictionary with count, total, mean, min, max or None if metric not found
44
+ """
45
+ if metric_name not in self._metrics:
46
+ return None
47
+
48
+ metrics = self._metrics[metric_name]
49
+ return {
50
+ "count": metrics["count"],
51
+ "total": metrics["total"],
52
+ "mean": metrics["total"] / metrics["count"] if metrics["count"] > 0 else 0.0,
53
+ "min": metrics["min"] if metrics["min"] != float("inf") else 0.0,
54
+ "max": metrics["max"],
55
+ }
56
+
57
+ def get_all_stats(self) -> Dict[str, Dict[str, float]]:
58
+ """Get statistics for all metrics.
59
+
60
+ Returns:
61
+ Dictionary mapping metric names to their statistics
62
+ """
63
+ return {name: self.get_stats(name) for name in self._metrics.keys()}
64
+
65
+ def reset(self, metric_name: str | None = None):
66
+ """Reset metrics.
67
+
68
+ Args:
69
+ metric_name: Specific metric to reset, or None to reset all
70
+ """
71
+ if metric_name:
72
+ if metric_name in self._metrics:
73
+ del self._metrics[metric_name]
74
+ else:
75
+ self._metrics.clear()
76
+
77
+ @asynccontextmanager
78
+ async def track(self, metric_name: str):
79
+ """Context manager for tracking operation duration.
80
+
81
+ Args:
82
+ metric_name: Name of the metric to track
83
+
84
+ Example:
85
+ async with monitor.track("api_call"):
86
+ result = await some_async_operation()
87
+ """
88
+ start_time = time.time()
89
+ try:
90
+ yield
91
+ finally:
92
+ duration = time.time() - start_time
93
+ self.record(metric_name, duration)
@@ -0,0 +1,75 @@
1
+ """
2
+ Pydantic 2 schema models for OpenAPI 3.0 connector specifications.
3
+
4
+ This package provides strongly-typed Pydantic models that mirror the OpenAPI 3.0
5
+ specification while supporting Airbyte-specific extensions.
6
+
7
+ Usage:
8
+ import yaml
9
+ from . import OpenAPIConnector
10
+
11
+ with open('connector.yaml') as f:
12
+ data = yaml.safe_load(f)
13
+
14
+ connector = OpenAPIConnector(**data)
15
+ print(connector.list_resources())
16
+ """
17
+
18
+ from .base import Contact, Info, License, Server, ServerVariable
19
+ from .components import (
20
+ Components,
21
+ Header,
22
+ MediaType,
23
+ Parameter,
24
+ RequestBody,
25
+ Response,
26
+ Schema,
27
+ )
28
+ from .connector import ExternalDocs, OpenAPIConnector, Tag
29
+ from .extensions import PaginationConfig, RateLimitConfig, RetryConfig
30
+ from .operations import Operation, PathItem
31
+ from .security import (
32
+ AirbyteAuthConfig,
33
+ AuthConfigFieldSpec,
34
+ AuthConfigOption,
35
+ OAuth2Flow,
36
+ OAuth2Flows,
37
+ SecurityRequirement,
38
+ SecurityScheme,
39
+ )
40
+
41
+ __all__ = [
42
+ # Root model
43
+ "OpenAPIConnector",
44
+ "Tag",
45
+ "ExternalDocs",
46
+ # Base models
47
+ "Info",
48
+ "Server",
49
+ "ServerVariable",
50
+ "Contact",
51
+ "License",
52
+ # Security models
53
+ "SecurityScheme",
54
+ "SecurityRequirement",
55
+ "OAuth2Flow",
56
+ "OAuth2Flows",
57
+ "AirbyteAuthConfig",
58
+ "AuthConfigOption",
59
+ "AuthConfigFieldSpec",
60
+ # Component models
61
+ "Components",
62
+ "Schema",
63
+ "Parameter",
64
+ "RequestBody",
65
+ "Response",
66
+ "MediaType",
67
+ "Header",
68
+ # Operation models
69
+ "PathItem",
70
+ "Operation",
71
+ # Extension models (for future use)
72
+ "PaginationConfig",
73
+ "RateLimitConfig",
74
+ "RetryConfig",
75
+ ]
@@ -0,0 +1,212 @@
1
+ """
2
+ Base OpenAPI 3.1 models: Info, Server, Contact, License.
3
+
4
+ References:
5
+ - https://spec.openapis.org/oas/v3.1.0#info-object
6
+ - https://spec.openapis.org/oas/v3.1.0#server-object
7
+ """
8
+
9
+ from enum import StrEnum
10
+ from typing import Any, Dict
11
+ from uuid import UUID
12
+
13
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
14
+ from pydantic_core import Url
15
+
16
+ from .extensions import CacheConfig, ReplicationConfig, RetryConfig
17
+
18
+
19
+ class ExampleQuestions(BaseModel):
20
+ """
21
+ Example questions for AI connector documentation.
22
+
23
+ Used to generate supported_questions.md and unsupported_questions.md files
24
+ that appear in the connector's README.
25
+ """
26
+
27
+ model_config = ConfigDict(populate_by_name=True, extra="forbid")
28
+
29
+ supported: list[str] = Field(
30
+ default_factory=list,
31
+ description="Example questions the connector can handle",
32
+ )
33
+ unsupported: list[str] = Field(
34
+ default_factory=list,
35
+ description="Example questions the connector cannot handle",
36
+ )
37
+
38
+
39
+ class Contact(BaseModel):
40
+ """
41
+ Contact information for the API.
42
+
43
+ OpenAPI Reference: https://spec.openapis.org/oas/v3.1.0#contact-object
44
+ """
45
+
46
+ model_config = ConfigDict(populate_by_name=True, extra="forbid")
47
+
48
+ name: str | None = None
49
+ url: str | None = None
50
+ email: str | None = None
51
+
52
+
53
+ class License(BaseModel):
54
+ """
55
+ License information for the API.
56
+
57
+ OpenAPI Reference: https://spec.openapis.org/oas/v3.1.0#license-object
58
+ """
59
+
60
+ model_config = ConfigDict(populate_by_name=True, extra="forbid")
61
+
62
+ name: str
63
+ url: str | None = None
64
+
65
+
66
+ class DocUrlType(StrEnum):
67
+ API_DEPRECATIONS = "api_deprecations"
68
+ API_REFERENCE = "api_reference"
69
+ API_RELEASE_HISTORY = "api_release_history"
70
+ AUTHENTICATION_GUIDE = "authentication_guide"
71
+ CHANGELOG = "changelog"
72
+ DATA_MODEL_REFERENCE = "data_model_reference"
73
+ DEVELOPER_COMMUNITY = "developer_community"
74
+ MIGRATION_GUIDE = "migration_guide"
75
+ OPENAPI_SPEC = "openapi_spec"
76
+ OTHER = "other"
77
+ PERMISSIONS_SCOPES = "permissions_scopes"
78
+ RATE_LIMITS = "rate_limits"
79
+ SQL_REFERENCE = "sql_reference"
80
+ STATUS_PAGE = "status_page"
81
+
82
+
83
+ class DocUrl(BaseModel):
84
+ model_config = ConfigDict(populate_by_name=True, extra="forbid")
85
+
86
+ url: str
87
+ type: DocUrlType
88
+ title: str | None = None
89
+
90
+ @field_validator("url")
91
+ def validate_url(cls, v):
92
+ Url(v)
93
+ return v
94
+
95
+
96
+ class Info(BaseModel):
97
+ """
98
+ API metadata information.
99
+
100
+ OpenAPI Reference: https://spec.openapis.org/oas/v3.1.0#info-object
101
+
102
+ Extensions:
103
+ - x-airbyte-connector-name: Name of the connector (Airbyte extension)
104
+ - x-airbyte-connector-id: UUID of the connector (Airbyte extension)
105
+ - x-airbyte-external-documentation-urls: List of external documentation URLs (Airbyte extension)
106
+ - x-airbyte-retry-config: Retry configuration for transient errors (Airbyte extension)
107
+ - x-airbyte-example-questions: Example questions for AI connector README (Airbyte extension)
108
+ - x-airbyte-cache: Cache configuration for field mapping between API and cache schemas (Airbyte extension)
109
+ - x-airbyte-replication-config: Replication configuration for MULTI mode connectors (Airbyte extension)
110
+ """
111
+
112
+ model_config = ConfigDict(populate_by_name=True, extra="forbid")
113
+
114
+ title: str
115
+ version: str
116
+ description: str | None = None
117
+ terms_of_service: str | None = Field(None, alias="termsOfService")
118
+ contact: Contact | None = None
119
+ license: License | None = None
120
+
121
+ # Airbyte extension
122
+ x_airbyte_connector_name: str | None = Field(None, alias="x-airbyte-connector-name")
123
+ x_airbyte_connector_id: UUID | None = Field(None, alias="x-airbyte-connector-id")
124
+ x_airbyte_external_documentation_urls: list[DocUrl] = Field(..., alias="x-airbyte-external-documentation-urls")
125
+ x_airbyte_retry_config: RetryConfig | None = Field(None, alias="x-airbyte-retry-config")
126
+ x_airbyte_example_questions: ExampleQuestions | None = Field(None, alias="x-airbyte-example-questions")
127
+ x_airbyte_cache: CacheConfig | None = Field(None, alias="x-airbyte-cache")
128
+ x_airbyte_replication_config: ReplicationConfig | None = Field(None, alias="x-airbyte-replication-config")
129
+ x_airbyte_skip_suggested_streams: list[str] = Field(
130
+ default_factory=list,
131
+ alias="x-airbyte-skip-suggested-streams",
132
+ description="List of Airbyte suggested streams to skip when validating cache entity coverage",
133
+ )
134
+ x_airbyte_skip_auth_methods: list[str] = Field(
135
+ default_factory=list,
136
+ alias="x-airbyte-skip-auth-methods",
137
+ description="List of Airbyte auth methods to skip when validating auth compatibility. "
138
+ "Use the SelectiveAuthenticator option key (e.g., 'Private App Credentials', 'oauth2.0')",
139
+ )
140
+
141
+
142
+ class ServerVariable(BaseModel):
143
+ """
144
+ Variable for server URL templating.
145
+
146
+ OpenAPI Reference: https://spec.openapis.org/oas/v3.1.0#server-variable-object
147
+ """
148
+
149
+ model_config = ConfigDict(populate_by_name=True, extra="forbid")
150
+
151
+ enum: list[str] | None = None
152
+ default: str
153
+ description: str | None = None
154
+
155
+
156
+ class EnvironmentMappingTransform(BaseModel):
157
+ """
158
+ Structured transform for environment mapping values.
159
+
160
+ Allows transforming environment values before storing in source_config.
161
+
162
+ Example:
163
+ source: subdomain
164
+ format: "{value}.atlassian.net"
165
+
166
+ The format string uses {value} as a placeholder for the source value.
167
+ """
168
+
169
+ model_config = ConfigDict(populate_by_name=True, extra="forbid")
170
+
171
+ source: str = Field(description="The environment config key to read the value from")
172
+ format: str | None = Field(
173
+ default=None,
174
+ description="Optional format string to transform the value. Use {value} as placeholder.",
175
+ )
176
+
177
+
178
+ # Type alias for environment mapping values: either a simple string (config key)
179
+ # or a structured transform with source and optional transform template
180
+ EnvironmentMappingValue = str | EnvironmentMappingTransform
181
+
182
+
183
+ class Server(BaseModel):
184
+ """
185
+ Server URL and variable definitions.
186
+
187
+ OpenAPI Reference: https://spec.openapis.org/oas/v3.1.0#server-object
188
+ """
189
+
190
+ model_config = ConfigDict(populate_by_name=True, extra="forbid")
191
+
192
+ url: str
193
+ description: str | None = None
194
+ variables: Dict[str, ServerVariable] = Field(default_factory=dict)
195
+ x_airbyte_replication_environment_mapping: Dict[str, EnvironmentMappingValue] | None = Field(
196
+ default=None,
197
+ alias="x-airbyte-replication-environment-mapping",
198
+ )
199
+ x_airbyte_replication_environment_constants: Dict[str, Any] | None = Field(
200
+ default=None,
201
+ alias="x-airbyte-replication-environment-constants",
202
+ description="Constant values to always inject at environment config paths (e.g., 'region': 'us-east-1')",
203
+ )
204
+
205
+ @field_validator("url")
206
+ @classmethod
207
+ def validate_url(cls, v: str) -> str:
208
+ """Validate that server URL is properly formatted."""
209
+ if not v:
210
+ raise ValueError("Server URL cannot be empty")
211
+ # Allow both absolute URLs and relative paths
212
+ return v