airbyte-agent-jira 0.1.22__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 (56) hide show
  1. airbyte_agent_jira/__init__.py +91 -0
  2. airbyte_agent_jira/_vendored/__init__.py +1 -0
  3. airbyte_agent_jira/_vendored/connector_sdk/__init__.py +82 -0
  4. airbyte_agent_jira/_vendored/connector_sdk/auth_strategies.py +1123 -0
  5. airbyte_agent_jira/_vendored/connector_sdk/auth_template.py +135 -0
  6. airbyte_agent_jira/_vendored/connector_sdk/cloud_utils/__init__.py +5 -0
  7. airbyte_agent_jira/_vendored/connector_sdk/cloud_utils/client.py +213 -0
  8. airbyte_agent_jira/_vendored/connector_sdk/connector_model_loader.py +965 -0
  9. airbyte_agent_jira/_vendored/connector_sdk/constants.py +78 -0
  10. airbyte_agent_jira/_vendored/connector_sdk/exceptions.py +23 -0
  11. airbyte_agent_jira/_vendored/connector_sdk/executor/__init__.py +31 -0
  12. airbyte_agent_jira/_vendored/connector_sdk/executor/hosted_executor.py +197 -0
  13. airbyte_agent_jira/_vendored/connector_sdk/executor/local_executor.py +1574 -0
  14. airbyte_agent_jira/_vendored/connector_sdk/executor/models.py +190 -0
  15. airbyte_agent_jira/_vendored/connector_sdk/extensions.py +694 -0
  16. airbyte_agent_jira/_vendored/connector_sdk/http/__init__.py +37 -0
  17. airbyte_agent_jira/_vendored/connector_sdk/http/adapters/__init__.py +9 -0
  18. airbyte_agent_jira/_vendored/connector_sdk/http/adapters/httpx_adapter.py +251 -0
  19. airbyte_agent_jira/_vendored/connector_sdk/http/config.py +98 -0
  20. airbyte_agent_jira/_vendored/connector_sdk/http/exceptions.py +119 -0
  21. airbyte_agent_jira/_vendored/connector_sdk/http/protocols.py +114 -0
  22. airbyte_agent_jira/_vendored/connector_sdk/http/response.py +102 -0
  23. airbyte_agent_jira/_vendored/connector_sdk/http_client.py +686 -0
  24. airbyte_agent_jira/_vendored/connector_sdk/introspection.py +262 -0
  25. airbyte_agent_jira/_vendored/connector_sdk/logging/__init__.py +11 -0
  26. airbyte_agent_jira/_vendored/connector_sdk/logging/logger.py +264 -0
  27. airbyte_agent_jira/_vendored/connector_sdk/logging/types.py +92 -0
  28. airbyte_agent_jira/_vendored/connector_sdk/observability/__init__.py +11 -0
  29. airbyte_agent_jira/_vendored/connector_sdk/observability/models.py +19 -0
  30. airbyte_agent_jira/_vendored/connector_sdk/observability/redactor.py +81 -0
  31. airbyte_agent_jira/_vendored/connector_sdk/observability/session.py +94 -0
  32. airbyte_agent_jira/_vendored/connector_sdk/performance/__init__.py +6 -0
  33. airbyte_agent_jira/_vendored/connector_sdk/performance/instrumentation.py +57 -0
  34. airbyte_agent_jira/_vendored/connector_sdk/performance/metrics.py +93 -0
  35. airbyte_agent_jira/_vendored/connector_sdk/schema/__init__.py +75 -0
  36. airbyte_agent_jira/_vendored/connector_sdk/schema/base.py +161 -0
  37. airbyte_agent_jira/_vendored/connector_sdk/schema/components.py +239 -0
  38. airbyte_agent_jira/_vendored/connector_sdk/schema/connector.py +131 -0
  39. airbyte_agent_jira/_vendored/connector_sdk/schema/extensions.py +109 -0
  40. airbyte_agent_jira/_vendored/connector_sdk/schema/operations.py +146 -0
  41. airbyte_agent_jira/_vendored/connector_sdk/schema/security.py +223 -0
  42. airbyte_agent_jira/_vendored/connector_sdk/secrets.py +182 -0
  43. airbyte_agent_jira/_vendored/connector_sdk/telemetry/__init__.py +10 -0
  44. airbyte_agent_jira/_vendored/connector_sdk/telemetry/config.py +32 -0
  45. airbyte_agent_jira/_vendored/connector_sdk/telemetry/events.py +58 -0
  46. airbyte_agent_jira/_vendored/connector_sdk/telemetry/tracker.py +151 -0
  47. airbyte_agent_jira/_vendored/connector_sdk/types.py +245 -0
  48. airbyte_agent_jira/_vendored/connector_sdk/utils.py +60 -0
  49. airbyte_agent_jira/_vendored/connector_sdk/validation.py +822 -0
  50. airbyte_agent_jira/connector.py +978 -0
  51. airbyte_agent_jira/connector_model.py +2827 -0
  52. airbyte_agent_jira/models.py +741 -0
  53. airbyte_agent_jira/types.py +117 -0
  54. airbyte_agent_jira-0.1.22.dist-info/METADATA +113 -0
  55. airbyte_agent_jira-0.1.22.dist-info/RECORD +56 -0
  56. airbyte_agent_jira-0.1.22.dist-info/WHEEL +4 -0
@@ -0,0 +1,81 @@
1
+ """Shared redaction logic for both logging and telemetry."""
2
+
3
+ from typing import Any, Dict
4
+ from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
5
+
6
+
7
+ class DataRedactor:
8
+ """Shared redaction logic for both logging and telemetry."""
9
+
10
+ SENSITIVE_HEADER_PATTERNS = [
11
+ "authorization",
12
+ "api-key",
13
+ "x-api-key",
14
+ "token",
15
+ "bearer",
16
+ "secret",
17
+ "password",
18
+ "credential",
19
+ ]
20
+
21
+ SENSITIVE_PARAM_PATTERNS = [
22
+ "password",
23
+ "secret",
24
+ "api_key",
25
+ "apikey",
26
+ "token",
27
+ "credentials",
28
+ "auth",
29
+ "key",
30
+ ]
31
+
32
+ @staticmethod
33
+ def redact_headers(headers: Dict[str, str]) -> Dict[str, str]:
34
+ """Redact sensitive headers."""
35
+ redacted = {}
36
+ for key, value in headers.items():
37
+ if any(pattern in key.lower() for pattern in DataRedactor.SENSITIVE_HEADER_PATTERNS):
38
+ redacted[key] = "***REDACTED***"
39
+ else:
40
+ redacted[key] = value
41
+ return redacted
42
+
43
+ @staticmethod
44
+ def redact_params(params: Dict[str, Any]) -> Dict[str, Any]:
45
+ """Redact sensitive parameters."""
46
+ redacted = {}
47
+ for key, value in params.items():
48
+ if any(pattern in key.lower() for pattern in DataRedactor.SENSITIVE_PARAM_PATTERNS):
49
+ redacted[key] = "***REDACTED***"
50
+ else:
51
+ redacted[key] = value
52
+ return redacted
53
+
54
+ @staticmethod
55
+ def redact_url(url: str) -> str:
56
+ """Redact sensitive query params from URL."""
57
+ parsed = urlparse(url)
58
+ if not parsed.query:
59
+ return url
60
+
61
+ params = parse_qs(parsed.query)
62
+ redacted_params = {}
63
+
64
+ for key, values in params.items():
65
+ if any(pattern in key.lower() for pattern in DataRedactor.SENSITIVE_PARAM_PATTERNS):
66
+ redacted_params[key] = ["***REDACTED***"] * len(values)
67
+ else:
68
+ redacted_params[key] = values
69
+
70
+ # Reconstruct URL with redacted params
71
+ new_query = urlencode(redacted_params, doseq=True)
72
+ return urlunparse(
73
+ (
74
+ parsed.scheme,
75
+ parsed.netloc,
76
+ parsed.path,
77
+ parsed.params,
78
+ new_query,
79
+ parsed.fragment,
80
+ )
81
+ )
@@ -0,0 +1,94 @@
1
+ """Shared session context for both logging and telemetry."""
2
+
3
+ import logging
4
+ import uuid
5
+ from datetime import UTC, datetime
6
+ from pathlib import Path
7
+ from typing import Any, Dict, Optional
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ def get_persistent_user_id() -> str:
13
+ """
14
+ Get or create an anonymous user ID stored in the home directory.
15
+
16
+ The ID is stored in ~/.airbyte/ai_sdk_user_id and persists across all sessions.
17
+ If the file doesn't exist, a new UUID is generated and saved.
18
+
19
+ Returns:
20
+ An anonymous UUID string that uniquely identifies this user across sessions.
21
+ """
22
+ try:
23
+ # Create .airbyte directory in home folder if it doesn't exist
24
+ airbyte_dir = Path.home() / ".airbyte"
25
+ airbyte_dir.mkdir(exist_ok=True)
26
+
27
+ # Path to user ID file
28
+ user_id_file = airbyte_dir / "ai_sdk_user_id"
29
+
30
+ # Try to read existing user ID
31
+ if user_id_file.exists():
32
+ user_id = user_id_file.read_text().strip()
33
+ if user_id: # Validate it's not empty
34
+ return user_id
35
+
36
+ # Generate new user ID if file doesn't exist or is empty
37
+ user_id = str(uuid.uuid4())
38
+ user_id_file.write_text(user_id)
39
+ logger.debug(f"Generated new anonymous user ID: {user_id}")
40
+
41
+ return user_id
42
+ except Exception as e:
43
+ # If we can't read/write the file, generate a session-only ID
44
+ logger.debug(f"Could not access anonymous user ID file: {e}")
45
+ return str(uuid.uuid4())
46
+
47
+
48
+ def get_public_ip() -> Optional[str]:
49
+ """
50
+ Fetch the public IP address of the user.
51
+
52
+ Returns None if unable to fetch (network issues, etc).
53
+ Uses httpx for a robust HTTP request to a public IP service.
54
+ """
55
+ try:
56
+ import httpx
57
+
58
+ # Use a short timeout to avoid blocking
59
+ with httpx.Client(timeout=2.0) as client:
60
+ response = client.get("https://api.ipify.org?format=text")
61
+ response.raise_for_status()
62
+ return response.text.strip()
63
+ except Exception:
64
+ # Never fail - just return None
65
+ return None
66
+
67
+
68
+ class ObservabilitySession:
69
+ """Shared session context for both logging and telemetry."""
70
+
71
+ def __init__(
72
+ self,
73
+ connector_name: str,
74
+ connector_version: Optional[str] = None,
75
+ execution_context: str = "direct",
76
+ session_id: Optional[str] = None,
77
+ ):
78
+ self.session_id = session_id or str(uuid.uuid4())
79
+ self.user_id = get_persistent_user_id()
80
+ self.connector_name = connector_name
81
+ self.connector_version = connector_version
82
+ self.execution_context = execution_context
83
+ self.started_at = datetime.now(UTC)
84
+ self.operation_count = 0
85
+ self.metadata: Dict[str, Any] = {}
86
+ self.public_ip = get_public_ip()
87
+
88
+ def increment_operations(self):
89
+ """Increment the operation counter."""
90
+ self.operation_count += 1
91
+
92
+ def duration_seconds(self) -> float:
93
+ """Calculate session duration in seconds."""
94
+ 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, Optional
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) -> Optional[Dict[str, float]]:
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: Optional[str] = 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,161 @@
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 Dict, Optional
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 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: Optional[str] = None
49
+ url: Optional[str] = None
50
+ email: Optional[str] = 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: Optional[str] = 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: Optional[str] = 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
+ """
109
+
110
+ model_config = ConfigDict(populate_by_name=True, extra="forbid")
111
+
112
+ title: str
113
+ version: str
114
+ description: Optional[str] = None
115
+ terms_of_service: Optional[str] = Field(None, alias="termsOfService")
116
+ contact: Optional[Contact] = None
117
+ license: Optional[License] = None
118
+
119
+ # Airbyte extension
120
+ x_airbyte_connector_name: Optional[str] = Field(None, alias="x-airbyte-connector-name")
121
+ x_airbyte_connector_id: Optional[UUID] = Field(None, alias="x-airbyte-connector-id")
122
+ x_airbyte_external_documentation_urls: list[DocUrl] = Field(..., alias="x-airbyte-external-documentation-urls")
123
+ x_airbyte_retry_config: Optional[RetryConfig] = Field(None, alias="x-airbyte-retry-config")
124
+ x_airbyte_example_questions: Optional[ExampleQuestions] = Field(None, alias="x-airbyte-example-questions")
125
+
126
+
127
+ class ServerVariable(BaseModel):
128
+ """
129
+ Variable for server URL templating.
130
+
131
+ OpenAPI Reference: https://spec.openapis.org/oas/v3.1.0#server-variable-object
132
+ """
133
+
134
+ model_config = ConfigDict(populate_by_name=True, extra="forbid")
135
+
136
+ enum: Optional[list[str]] = None
137
+ default: str
138
+ description: Optional[str] = None
139
+
140
+
141
+ class Server(BaseModel):
142
+ """
143
+ Server URL and variable definitions.
144
+
145
+ OpenAPI Reference: https://spec.openapis.org/oas/v3.1.0#server-object
146
+ """
147
+
148
+ model_config = ConfigDict(populate_by_name=True, extra="forbid")
149
+
150
+ url: str
151
+ description: Optional[str] = None
152
+ variables: Dict[str, ServerVariable] = Field(default_factory=dict)
153
+
154
+ @field_validator("url")
155
+ @classmethod
156
+ def validate_url(cls, v: str) -> str:
157
+ """Validate that server URL is properly formatted."""
158
+ if not v:
159
+ raise ValueError("Server URL cannot be empty")
160
+ # Allow both absolute URLs and relative paths
161
+ return v