ccproxy-api 0.1.0__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 (148) hide show
  1. ccproxy/__init__.py +4 -0
  2. ccproxy/__main__.py +7 -0
  3. ccproxy/_version.py +21 -0
  4. ccproxy/adapters/__init__.py +11 -0
  5. ccproxy/adapters/base.py +80 -0
  6. ccproxy/adapters/openai/__init__.py +43 -0
  7. ccproxy/adapters/openai/adapter.py +915 -0
  8. ccproxy/adapters/openai/models.py +412 -0
  9. ccproxy/adapters/openai/streaming.py +449 -0
  10. ccproxy/api/__init__.py +28 -0
  11. ccproxy/api/app.py +225 -0
  12. ccproxy/api/dependencies.py +140 -0
  13. ccproxy/api/middleware/__init__.py +11 -0
  14. ccproxy/api/middleware/auth.py +0 -0
  15. ccproxy/api/middleware/cors.py +55 -0
  16. ccproxy/api/middleware/errors.py +703 -0
  17. ccproxy/api/middleware/headers.py +51 -0
  18. ccproxy/api/middleware/logging.py +175 -0
  19. ccproxy/api/middleware/request_id.py +69 -0
  20. ccproxy/api/middleware/server_header.py +62 -0
  21. ccproxy/api/responses.py +84 -0
  22. ccproxy/api/routes/__init__.py +16 -0
  23. ccproxy/api/routes/claude.py +181 -0
  24. ccproxy/api/routes/health.py +489 -0
  25. ccproxy/api/routes/metrics.py +1033 -0
  26. ccproxy/api/routes/proxy.py +238 -0
  27. ccproxy/auth/__init__.py +75 -0
  28. ccproxy/auth/bearer.py +68 -0
  29. ccproxy/auth/credentials_adapter.py +93 -0
  30. ccproxy/auth/dependencies.py +229 -0
  31. ccproxy/auth/exceptions.py +79 -0
  32. ccproxy/auth/manager.py +102 -0
  33. ccproxy/auth/models.py +118 -0
  34. ccproxy/auth/oauth/__init__.py +26 -0
  35. ccproxy/auth/oauth/models.py +49 -0
  36. ccproxy/auth/oauth/routes.py +396 -0
  37. ccproxy/auth/oauth/storage.py +0 -0
  38. ccproxy/auth/storage/__init__.py +12 -0
  39. ccproxy/auth/storage/base.py +57 -0
  40. ccproxy/auth/storage/json_file.py +159 -0
  41. ccproxy/auth/storage/keyring.py +192 -0
  42. ccproxy/claude_sdk/__init__.py +20 -0
  43. ccproxy/claude_sdk/client.py +169 -0
  44. ccproxy/claude_sdk/converter.py +331 -0
  45. ccproxy/claude_sdk/options.py +120 -0
  46. ccproxy/cli/__init__.py +14 -0
  47. ccproxy/cli/commands/__init__.py +8 -0
  48. ccproxy/cli/commands/auth.py +553 -0
  49. ccproxy/cli/commands/config/__init__.py +14 -0
  50. ccproxy/cli/commands/config/commands.py +766 -0
  51. ccproxy/cli/commands/config/schema_commands.py +119 -0
  52. ccproxy/cli/commands/serve.py +630 -0
  53. ccproxy/cli/docker/__init__.py +34 -0
  54. ccproxy/cli/docker/adapter_factory.py +157 -0
  55. ccproxy/cli/docker/params.py +278 -0
  56. ccproxy/cli/helpers.py +144 -0
  57. ccproxy/cli/main.py +193 -0
  58. ccproxy/cli/options/__init__.py +14 -0
  59. ccproxy/cli/options/claude_options.py +216 -0
  60. ccproxy/cli/options/core_options.py +40 -0
  61. ccproxy/cli/options/security_options.py +48 -0
  62. ccproxy/cli/options/server_options.py +117 -0
  63. ccproxy/config/__init__.py +40 -0
  64. ccproxy/config/auth.py +154 -0
  65. ccproxy/config/claude.py +124 -0
  66. ccproxy/config/cors.py +79 -0
  67. ccproxy/config/discovery.py +87 -0
  68. ccproxy/config/docker_settings.py +265 -0
  69. ccproxy/config/loader.py +108 -0
  70. ccproxy/config/observability.py +158 -0
  71. ccproxy/config/pricing.py +88 -0
  72. ccproxy/config/reverse_proxy.py +31 -0
  73. ccproxy/config/scheduler.py +89 -0
  74. ccproxy/config/security.py +14 -0
  75. ccproxy/config/server.py +81 -0
  76. ccproxy/config/settings.py +534 -0
  77. ccproxy/config/validators.py +231 -0
  78. ccproxy/core/__init__.py +274 -0
  79. ccproxy/core/async_utils.py +675 -0
  80. ccproxy/core/constants.py +97 -0
  81. ccproxy/core/errors.py +256 -0
  82. ccproxy/core/http.py +328 -0
  83. ccproxy/core/http_transformers.py +428 -0
  84. ccproxy/core/interfaces.py +247 -0
  85. ccproxy/core/logging.py +189 -0
  86. ccproxy/core/middleware.py +114 -0
  87. ccproxy/core/proxy.py +143 -0
  88. ccproxy/core/system.py +38 -0
  89. ccproxy/core/transformers.py +259 -0
  90. ccproxy/core/types.py +129 -0
  91. ccproxy/core/validators.py +288 -0
  92. ccproxy/docker/__init__.py +67 -0
  93. ccproxy/docker/adapter.py +588 -0
  94. ccproxy/docker/docker_path.py +207 -0
  95. ccproxy/docker/middleware.py +103 -0
  96. ccproxy/docker/models.py +228 -0
  97. ccproxy/docker/protocol.py +192 -0
  98. ccproxy/docker/stream_process.py +264 -0
  99. ccproxy/docker/validators.py +173 -0
  100. ccproxy/models/__init__.py +123 -0
  101. ccproxy/models/errors.py +42 -0
  102. ccproxy/models/messages.py +243 -0
  103. ccproxy/models/requests.py +85 -0
  104. ccproxy/models/responses.py +227 -0
  105. ccproxy/models/types.py +102 -0
  106. ccproxy/observability/__init__.py +51 -0
  107. ccproxy/observability/access_logger.py +400 -0
  108. ccproxy/observability/context.py +447 -0
  109. ccproxy/observability/metrics.py +539 -0
  110. ccproxy/observability/pushgateway.py +366 -0
  111. ccproxy/observability/sse_events.py +303 -0
  112. ccproxy/observability/stats_printer.py +755 -0
  113. ccproxy/observability/storage/__init__.py +1 -0
  114. ccproxy/observability/storage/duckdb_simple.py +665 -0
  115. ccproxy/observability/storage/models.py +55 -0
  116. ccproxy/pricing/__init__.py +19 -0
  117. ccproxy/pricing/cache.py +212 -0
  118. ccproxy/pricing/loader.py +267 -0
  119. ccproxy/pricing/models.py +106 -0
  120. ccproxy/pricing/updater.py +309 -0
  121. ccproxy/scheduler/__init__.py +39 -0
  122. ccproxy/scheduler/core.py +335 -0
  123. ccproxy/scheduler/exceptions.py +34 -0
  124. ccproxy/scheduler/manager.py +186 -0
  125. ccproxy/scheduler/registry.py +150 -0
  126. ccproxy/scheduler/tasks.py +484 -0
  127. ccproxy/services/__init__.py +10 -0
  128. ccproxy/services/claude_sdk_service.py +614 -0
  129. ccproxy/services/credentials/__init__.py +55 -0
  130. ccproxy/services/credentials/config.py +105 -0
  131. ccproxy/services/credentials/manager.py +562 -0
  132. ccproxy/services/credentials/oauth_client.py +482 -0
  133. ccproxy/services/proxy_service.py +1536 -0
  134. ccproxy/static/.keep +0 -0
  135. ccproxy/testing/__init__.py +34 -0
  136. ccproxy/testing/config.py +148 -0
  137. ccproxy/testing/content_generation.py +197 -0
  138. ccproxy/testing/mock_responses.py +262 -0
  139. ccproxy/testing/response_handlers.py +161 -0
  140. ccproxy/testing/scenarios.py +241 -0
  141. ccproxy/utils/__init__.py +6 -0
  142. ccproxy/utils/cost_calculator.py +210 -0
  143. ccproxy/utils/streaming_metrics.py +199 -0
  144. ccproxy_api-0.1.0.dist-info/METADATA +253 -0
  145. ccproxy_api-0.1.0.dist-info/RECORD +148 -0
  146. ccproxy_api-0.1.0.dist-info/WHEEL +4 -0
  147. ccproxy_api-0.1.0.dist-info/entry_points.txt +2 -0
  148. ccproxy_api-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,259 @@
1
+ """Core transformer abstractions for request/response transformation."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import TYPE_CHECKING, Any, Optional, Protocol, TypeVar, runtime_checkable
5
+
6
+ from structlog import get_logger
7
+
8
+ from ccproxy.core.types import ProxyRequest, ProxyResponse, TransformContext
9
+
10
+
11
+ if TYPE_CHECKING:
12
+ pass
13
+
14
+
15
+ T = TypeVar("T", contravariant=True)
16
+ R = TypeVar("R", covariant=True)
17
+
18
+
19
+ class BaseTransformer(ABC):
20
+ """Abstract base class for all transformers."""
21
+
22
+ def __init__(self) -> None:
23
+ """Initialize transformer."""
24
+ self.metrics_collector: Any = None
25
+
26
+ @abstractmethod
27
+ async def transform(
28
+ self, data: Any, context: TransformContext | None = None
29
+ ) -> Any:
30
+ """Transform the input data.
31
+
32
+ Args:
33
+ data: The data to transform
34
+ context: Optional transformation context
35
+
36
+ Returns:
37
+ The transformed data
38
+
39
+ Raises:
40
+ TransformationError: If transformation fails
41
+ """
42
+ pass
43
+
44
+ async def _collect_transformation_metrics(
45
+ self,
46
+ transformation_type: str,
47
+ input_data: Any,
48
+ output_data: Any,
49
+ duration_ms: float,
50
+ success: bool = True,
51
+ error: str | None = None,
52
+ ) -> None:
53
+ """Collect metrics for transformation operations.
54
+
55
+ Args:
56
+ transformation_type: Type of transformation (request/response)
57
+ input_data: Original input data
58
+ output_data: Transformed output data
59
+ duration_ms: Time taken for transformation in milliseconds
60
+ success: Whether transformation was successful
61
+ error: Error message if transformation failed
62
+ """
63
+ if not self.metrics_collector:
64
+ return
65
+
66
+ try:
67
+ # Calculate data sizes
68
+ input_size = self._calculate_data_size(input_data)
69
+ output_size = self._calculate_data_size(output_data) if output_data else 0
70
+
71
+ # Create a unique request ID for this transformation
72
+ request_id = (
73
+ f"transformer_{id(self)}_{transformation_type}_{id(input_data)}"
74
+ )
75
+
76
+ # Use existing latency collection method with timing data
77
+ await self.metrics_collector.collect_latency(
78
+ request_id=request_id,
79
+ transformation_duration=duration_ms,
80
+ processing_time=duration_ms,
81
+ )
82
+
83
+ except Exception as e:
84
+ # Don't let metrics collection fail the transformation
85
+ logger = get_logger(__name__)
86
+ # logger = logging.getLogger(__name__)
87
+ logger.debug(
88
+ "transformation_metrics_failed",
89
+ error=str(e),
90
+ operation="collect_transformation_metrics",
91
+ )
92
+
93
+ def _calculate_data_size(self, data: Any) -> int:
94
+ """Calculate the size of data in bytes.
95
+
96
+ Args:
97
+ data: The data to measure
98
+
99
+ Returns:
100
+ Size in bytes
101
+ """
102
+ if data is None:
103
+ return 0
104
+ elif isinstance(data, bytes):
105
+ return len(data)
106
+ elif isinstance(data, str):
107
+ return len(data.encode("utf-8"))
108
+ elif hasattr(data, "__len__"):
109
+ return len(str(data))
110
+ else:
111
+ return len(str(data))
112
+
113
+
114
+ class RequestTransformer(BaseTransformer):
115
+ """Base class for request transformers."""
116
+
117
+ async def transform(
118
+ self, request: ProxyRequest, context: TransformContext | None = None
119
+ ) -> ProxyRequest:
120
+ """Transform a proxy request with metrics collection.
121
+
122
+ Args:
123
+ request: The request to transform
124
+ context: Optional transformation context
125
+
126
+ Returns:
127
+ The transformed request
128
+ """
129
+ import time
130
+
131
+ start_time = time.perf_counter()
132
+ error_msg = None
133
+ result = None
134
+
135
+ try:
136
+ result = await self._transform_request(request, context)
137
+ return result
138
+ except Exception as e:
139
+ error_msg = str(e)
140
+ raise
141
+ finally:
142
+ # Collect metrics regardless of success/failure
143
+ duration_ms = (time.perf_counter() - start_time) * 1000
144
+ await self._collect_transformation_metrics(
145
+ transformation_type="request",
146
+ input_data=request,
147
+ output_data=result,
148
+ duration_ms=duration_ms,
149
+ success=error_msg is None,
150
+ error=error_msg,
151
+ )
152
+
153
+ @abstractmethod
154
+ async def _transform_request(
155
+ self, request: ProxyRequest, context: TransformContext | None = None
156
+ ) -> ProxyRequest:
157
+ """Transform a proxy request implementation.
158
+
159
+ Args:
160
+ request: The request to transform
161
+ context: Optional transformation context
162
+
163
+ Returns:
164
+ The transformed request
165
+ """
166
+ pass
167
+
168
+
169
+ class ResponseTransformer(BaseTransformer):
170
+ """Base class for response transformers."""
171
+
172
+ async def transform(
173
+ self, response: ProxyResponse, context: TransformContext | None = None
174
+ ) -> ProxyResponse:
175
+ """Transform a proxy response with metrics collection.
176
+
177
+ Args:
178
+ response: The response to transform
179
+ context: Optional transformation context
180
+
181
+ Returns:
182
+ The transformed response
183
+ """
184
+ import time
185
+
186
+ start_time = time.perf_counter()
187
+ error_msg = None
188
+ result = None
189
+
190
+ try:
191
+ result = await self._transform_response(response, context)
192
+ return result
193
+ except Exception as e:
194
+ error_msg = str(e)
195
+ raise
196
+ finally:
197
+ # Collect metrics regardless of success/failure
198
+ duration_ms = (time.perf_counter() - start_time) * 1000
199
+ await self._collect_transformation_metrics(
200
+ transformation_type="response",
201
+ input_data=response,
202
+ output_data=result,
203
+ duration_ms=duration_ms,
204
+ success=error_msg is None,
205
+ error=error_msg,
206
+ )
207
+
208
+ @abstractmethod
209
+ async def _transform_response(
210
+ self, response: ProxyResponse, context: TransformContext | None = None
211
+ ) -> ProxyResponse:
212
+ """Transform a proxy response implementation.
213
+
214
+ Args:
215
+ response: The response to transform
216
+ context: Optional transformation context
217
+
218
+ Returns:
219
+ The transformed response
220
+ """
221
+ pass
222
+
223
+
224
+ @runtime_checkable
225
+ class TransformerProtocol(Protocol[T, R]):
226
+ """Protocol defining the transformer interface."""
227
+
228
+ async def transform(self, data: T, context: TransformContext | None = None) -> R:
229
+ """Transform the input data."""
230
+ ...
231
+
232
+
233
+ class ChainedTransformer(BaseTransformer):
234
+ """Transformer that chains multiple transformers together."""
235
+
236
+ def __init__(self, transformers: list[BaseTransformer]):
237
+ """Initialize with a list of transformers to chain.
238
+
239
+ Args:
240
+ transformers: List of transformers to apply in sequence
241
+ """
242
+ self.transformers = transformers
243
+
244
+ async def transform(
245
+ self, data: Any, context: TransformContext | None = None
246
+ ) -> Any:
247
+ """Apply all transformers in sequence.
248
+
249
+ Args:
250
+ data: The data to transform
251
+ context: Optional transformation context
252
+
253
+ Returns:
254
+ The result of applying all transformers
255
+ """
256
+ result = data
257
+ for transformer in self.transformers:
258
+ result = await transformer.transform(result, context)
259
+ return result
ccproxy/core/types.py ADDED
@@ -0,0 +1,129 @@
1
+ """Core type definitions for the proxy system."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from enum import Enum
5
+ from typing import Any, Optional, Union
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+
10
+ class ProxyMethod(str, Enum):
11
+ """HTTP methods supported by the proxy."""
12
+
13
+ GET = "GET"
14
+ POST = "POST"
15
+ PUT = "PUT"
16
+ DELETE = "DELETE"
17
+ PATCH = "PATCH"
18
+ HEAD = "HEAD"
19
+ OPTIONS = "OPTIONS"
20
+ CONNECT = "CONNECT"
21
+ TRACE = "TRACE"
22
+
23
+
24
+ class ProxyProtocol(str, Enum):
25
+ """Protocols supported by the proxy."""
26
+
27
+ HTTP = "http"
28
+ HTTPS = "https"
29
+ WS = "ws"
30
+ WSS = "wss"
31
+
32
+
33
+ @dataclass
34
+ class ProxyRequest:
35
+ """Represents a proxy request."""
36
+
37
+ method: ProxyMethod
38
+ url: str
39
+ headers: dict[str, str] = field(default_factory=dict)
40
+ params: dict[str, Any] = field(default_factory=dict)
41
+ body: str | bytes | dict[str, Any] | None = None
42
+ protocol: ProxyProtocol = ProxyProtocol.HTTPS
43
+ timeout: float | None = None
44
+ metadata: dict[str, Any] = field(default_factory=dict)
45
+
46
+ def __post_init__(self) -> None:
47
+ """Validate and normalize the request."""
48
+ if isinstance(self.method, str):
49
+ self.method = ProxyMethod(self.method.upper())
50
+ if isinstance(self.protocol, str):
51
+ self.protocol = ProxyProtocol(self.protocol.lower())
52
+
53
+
54
+ @dataclass
55
+ class ProxyResponse:
56
+ """Represents a proxy response."""
57
+
58
+ status_code: int
59
+ headers: dict[str, str] = field(default_factory=dict)
60
+ body: str | bytes | dict[str, Any] | None = None
61
+ metadata: dict[str, Any] = field(default_factory=dict)
62
+
63
+ @property
64
+ def is_success(self) -> bool:
65
+ """Check if the response indicates success."""
66
+ return 200 <= self.status_code < 300
67
+
68
+ @property
69
+ def is_error(self) -> bool:
70
+ """Check if the response indicates an error."""
71
+ return self.status_code >= 400
72
+
73
+
74
+ @dataclass
75
+ class TransformContext:
76
+ """Context passed to transformers during transformation."""
77
+
78
+ request: ProxyRequest | None = None
79
+ response: ProxyResponse | None = None
80
+ metadata: dict[str, Any] = field(default_factory=dict)
81
+
82
+ def get(self, key: str, default: Any = None) -> Any:
83
+ """Get a value from metadata."""
84
+ return self.metadata.get(key, default)
85
+
86
+ def set(self, key: str, value: Any) -> None:
87
+ """Set a value in metadata."""
88
+ self.metadata[key] = value
89
+
90
+
91
+ class ProxyConfig(BaseModel):
92
+ """Configuration for proxy behavior."""
93
+
94
+ timeout: float = Field(default=30.0, description="Default timeout in seconds")
95
+ max_retries: int = Field(default=3, description="Maximum number of retries")
96
+ retry_delay: float = Field(
97
+ default=1.0, description="Delay between retries in seconds"
98
+ )
99
+ verify_ssl: bool = Field(
100
+ default=True, description="Whether to verify SSL certificates"
101
+ )
102
+ follow_redirects: bool = Field(
103
+ default=True, description="Whether to follow redirects"
104
+ )
105
+ max_redirects: int = Field(
106
+ default=10, description="Maximum number of redirects to follow"
107
+ )
108
+
109
+ class Config:
110
+ """Pydantic configuration."""
111
+
112
+ extra = "forbid"
113
+
114
+
115
+ class MiddlewareConfig(BaseModel):
116
+ """Configuration for middleware behavior."""
117
+
118
+ enabled: bool = Field(default=True, description="Whether the middleware is enabled")
119
+ priority: int = Field(
120
+ default=0, description="Middleware execution priority (lower = earlier)"
121
+ )
122
+ metadata: dict[str, Any] = Field(
123
+ default_factory=dict, description="Additional middleware configuration"
124
+ )
125
+
126
+ class Config:
127
+ """Pydantic configuration."""
128
+
129
+ extra = "allow"
@@ -0,0 +1,288 @@
1
+ """Generic validation utilities for the CCProxy API."""
2
+
3
+ import re
4
+ from pathlib import Path
5
+ from typing import Any
6
+ from urllib.parse import urlparse
7
+
8
+ from ccproxy.core.constants import EMAIL_PATTERN, URL_PATTERN, UUID_PATTERN
9
+
10
+
11
+ class ValidationError(Exception):
12
+ """Base class for validation errors."""
13
+
14
+ pass
15
+
16
+
17
+ def validate_email(email: str) -> str:
18
+ """Validate email format.
19
+
20
+ Args:
21
+ email: Email address to validate
22
+
23
+ Returns:
24
+ The validated email address
25
+
26
+ Raises:
27
+ ValidationError: If email format is invalid
28
+ """
29
+ if not isinstance(email, str):
30
+ raise ValidationError("Email must be a string")
31
+
32
+ if not re.match(EMAIL_PATTERN, email):
33
+ raise ValidationError(f"Invalid email format: {email}")
34
+
35
+ return email.strip().lower()
36
+
37
+
38
+ def validate_url(url: str) -> str:
39
+ """Validate URL format.
40
+
41
+ Args:
42
+ url: URL to validate
43
+
44
+ Returns:
45
+ The validated URL
46
+
47
+ Raises:
48
+ ValidationError: If URL format is invalid
49
+ """
50
+ if not isinstance(url, str):
51
+ raise ValidationError("URL must be a string")
52
+
53
+ if not re.match(URL_PATTERN, url):
54
+ raise ValidationError(f"Invalid URL format: {url}")
55
+
56
+ try:
57
+ parsed = urlparse(url)
58
+ if not parsed.scheme or not parsed.netloc:
59
+ raise ValidationError(f"Invalid URL format: {url}")
60
+ except Exception as e:
61
+ raise ValidationError(f"Invalid URL format: {url}") from e
62
+
63
+ return url.strip()
64
+
65
+
66
+ def validate_uuid(uuid_str: str) -> str:
67
+ """Validate UUID format.
68
+
69
+ Args:
70
+ uuid_str: UUID string to validate
71
+
72
+ Returns:
73
+ The validated UUID string
74
+
75
+ Raises:
76
+ ValidationError: If UUID format is invalid
77
+ """
78
+ if not isinstance(uuid_str, str):
79
+ raise ValidationError("UUID must be a string")
80
+
81
+ if not re.match(UUID_PATTERN, uuid_str.lower()):
82
+ raise ValidationError(f"Invalid UUID format: {uuid_str}")
83
+
84
+ return uuid_str.strip().lower()
85
+
86
+
87
+ def validate_path(path: str | Path, must_exist: bool = True) -> Path:
88
+ """Validate file system path.
89
+
90
+ Args:
91
+ path: Path to validate
92
+ must_exist: Whether the path must exist
93
+
94
+ Returns:
95
+ The validated Path object
96
+
97
+ Raises:
98
+ ValidationError: If path is invalid
99
+ """
100
+ if isinstance(path, str):
101
+ path = Path(path)
102
+ elif not isinstance(path, Path):
103
+ raise ValidationError("Path must be a string or Path object")
104
+
105
+ if must_exist and not path.exists():
106
+ raise ValidationError(f"Path does not exist: {path}")
107
+
108
+ return path.resolve()
109
+
110
+
111
+ def validate_port(port: int | str) -> int:
112
+ """Validate port number.
113
+
114
+ Args:
115
+ port: Port number to validate
116
+
117
+ Returns:
118
+ The validated port number
119
+
120
+ Raises:
121
+ ValidationError: If port is invalid
122
+ """
123
+ if isinstance(port, str):
124
+ try:
125
+ port = int(port)
126
+ except ValueError as e:
127
+ raise ValidationError(f"Port must be a valid integer: {port}") from e
128
+
129
+ if not isinstance(port, int):
130
+ raise ValidationError(f"Port must be an integer: {port}")
131
+
132
+ if port < 1 or port > 65535:
133
+ raise ValidationError(f"Port must be between 1 and 65535: {port}")
134
+
135
+ return port
136
+
137
+
138
+ def validate_timeout(timeout: float | int | str) -> float:
139
+ """Validate timeout value.
140
+
141
+ Args:
142
+ timeout: Timeout value to validate
143
+
144
+ Returns:
145
+ The validated timeout value
146
+
147
+ Raises:
148
+ ValidationError: If timeout is invalid
149
+ """
150
+ if isinstance(timeout, str):
151
+ try:
152
+ timeout = float(timeout)
153
+ except ValueError as e:
154
+ raise ValidationError(f"Timeout must be a valid number: {timeout}") from e
155
+
156
+ if not isinstance(timeout, int | float):
157
+ raise ValidationError(f"Timeout must be a number: {timeout}")
158
+
159
+ if timeout < 0:
160
+ raise ValidationError(f"Timeout must be non-negative: {timeout}")
161
+
162
+ return float(timeout)
163
+
164
+
165
+ def validate_non_empty_string(value: str, name: str = "value") -> str:
166
+ """Validate that a string is not empty.
167
+
168
+ Args:
169
+ value: String value to validate
170
+ name: Name of the field for error messages
171
+
172
+ Returns:
173
+ The validated string
174
+
175
+ Raises:
176
+ ValidationError: If string is empty or not a string
177
+ """
178
+ if not isinstance(value, str):
179
+ raise ValidationError(f"{name} must be a string")
180
+
181
+ if not value.strip():
182
+ raise ValidationError(f"{name} cannot be empty")
183
+
184
+ return value.strip()
185
+
186
+
187
+ def validate_dict(value: Any, required_keys: list[str] | None = None) -> dict[str, Any]:
188
+ """Validate dictionary and required keys.
189
+
190
+ Args:
191
+ value: Value to validate as dictionary
192
+ required_keys: List of required keys
193
+
194
+ Returns:
195
+ The validated dictionary
196
+
197
+ Raises:
198
+ ValidationError: If not a dictionary or missing required keys
199
+ """
200
+ if not isinstance(value, dict):
201
+ raise ValidationError("Value must be a dictionary")
202
+
203
+ if required_keys:
204
+ missing_keys = [key for key in required_keys if key not in value]
205
+ if missing_keys:
206
+ raise ValidationError(f"Missing required keys: {missing_keys}")
207
+
208
+ return value
209
+
210
+
211
+ def validate_list(
212
+ value: Any, min_length: int = 0, max_length: int | None = None
213
+ ) -> list[Any]:
214
+ """Validate list and length constraints.
215
+
216
+ Args:
217
+ value: Value to validate as list
218
+ min_length: Minimum list length
219
+ max_length: Maximum list length
220
+
221
+ Returns:
222
+ The validated list
223
+
224
+ Raises:
225
+ ValidationError: If not a list or length constraints are violated
226
+ """
227
+ if not isinstance(value, list):
228
+ raise ValidationError("Value must be a list")
229
+
230
+ if len(value) < min_length:
231
+ raise ValidationError(f"List must have at least {min_length} items")
232
+
233
+ if max_length is not None and len(value) > max_length:
234
+ raise ValidationError(f"List cannot have more than {max_length} items")
235
+
236
+ return value
237
+
238
+
239
+ def validate_choice(value: Any, choices: list[Any], name: str = "value") -> Any:
240
+ """Validate that value is one of the allowed choices.
241
+
242
+ Args:
243
+ value: Value to validate
244
+ choices: List of allowed choices
245
+ name: Name of the field for error messages
246
+
247
+ Returns:
248
+ The validated value
249
+
250
+ Raises:
251
+ ValidationError: If value is not in choices
252
+ """
253
+ if value not in choices:
254
+ raise ValidationError(f"{name} must be one of {choices}, got: {value}")
255
+
256
+ return value
257
+
258
+
259
+ def validate_range(
260
+ value: float | int,
261
+ min_value: float | int | None = None,
262
+ max_value: float | int | None = None,
263
+ name: str = "value",
264
+ ) -> float | int:
265
+ """Validate that a numeric value is within a specified range.
266
+
267
+ Args:
268
+ value: Numeric value to validate
269
+ min_value: Minimum allowed value
270
+ max_value: Maximum allowed value
271
+ name: Name of the field for error messages
272
+
273
+ Returns:
274
+ The validated value
275
+
276
+ Raises:
277
+ ValidationError: If value is outside the allowed range
278
+ """
279
+ if not isinstance(value, int | float):
280
+ raise ValidationError(f"{name} must be a number")
281
+
282
+ if min_value is not None and value < min_value:
283
+ raise ValidationError(f"{name} must be at least {min_value}")
284
+
285
+ if max_value is not None and value > max_value:
286
+ raise ValidationError(f"{name} must be at most {max_value}")
287
+
288
+ return value