mcp-hangar 0.2.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 (160) hide show
  1. mcp_hangar/__init__.py +139 -0
  2. mcp_hangar/application/__init__.py +1 -0
  3. mcp_hangar/application/commands/__init__.py +67 -0
  4. mcp_hangar/application/commands/auth_commands.py +118 -0
  5. mcp_hangar/application/commands/auth_handlers.py +296 -0
  6. mcp_hangar/application/commands/commands.py +59 -0
  7. mcp_hangar/application/commands/handlers.py +189 -0
  8. mcp_hangar/application/discovery/__init__.py +21 -0
  9. mcp_hangar/application/discovery/discovery_metrics.py +283 -0
  10. mcp_hangar/application/discovery/discovery_orchestrator.py +497 -0
  11. mcp_hangar/application/discovery/lifecycle_manager.py +315 -0
  12. mcp_hangar/application/discovery/security_validator.py +414 -0
  13. mcp_hangar/application/event_handlers/__init__.py +50 -0
  14. mcp_hangar/application/event_handlers/alert_handler.py +191 -0
  15. mcp_hangar/application/event_handlers/audit_handler.py +203 -0
  16. mcp_hangar/application/event_handlers/knowledge_base_handler.py +120 -0
  17. mcp_hangar/application/event_handlers/logging_handler.py +69 -0
  18. mcp_hangar/application/event_handlers/metrics_handler.py +152 -0
  19. mcp_hangar/application/event_handlers/persistent_audit_store.py +217 -0
  20. mcp_hangar/application/event_handlers/security_handler.py +604 -0
  21. mcp_hangar/application/mcp/tooling.py +158 -0
  22. mcp_hangar/application/ports/__init__.py +9 -0
  23. mcp_hangar/application/ports/observability.py +237 -0
  24. mcp_hangar/application/queries/__init__.py +52 -0
  25. mcp_hangar/application/queries/auth_handlers.py +237 -0
  26. mcp_hangar/application/queries/auth_queries.py +118 -0
  27. mcp_hangar/application/queries/handlers.py +227 -0
  28. mcp_hangar/application/read_models/__init__.py +11 -0
  29. mcp_hangar/application/read_models/provider_views.py +139 -0
  30. mcp_hangar/application/sagas/__init__.py +11 -0
  31. mcp_hangar/application/sagas/group_rebalance_saga.py +137 -0
  32. mcp_hangar/application/sagas/provider_failover_saga.py +266 -0
  33. mcp_hangar/application/sagas/provider_recovery_saga.py +172 -0
  34. mcp_hangar/application/services/__init__.py +9 -0
  35. mcp_hangar/application/services/provider_service.py +208 -0
  36. mcp_hangar/application/services/traced_provider_service.py +211 -0
  37. mcp_hangar/bootstrap/runtime.py +328 -0
  38. mcp_hangar/context.py +178 -0
  39. mcp_hangar/domain/__init__.py +117 -0
  40. mcp_hangar/domain/contracts/__init__.py +57 -0
  41. mcp_hangar/domain/contracts/authentication.py +225 -0
  42. mcp_hangar/domain/contracts/authorization.py +229 -0
  43. mcp_hangar/domain/contracts/event_store.py +178 -0
  44. mcp_hangar/domain/contracts/metrics_publisher.py +59 -0
  45. mcp_hangar/domain/contracts/persistence.py +383 -0
  46. mcp_hangar/domain/contracts/provider_runtime.py +146 -0
  47. mcp_hangar/domain/discovery/__init__.py +20 -0
  48. mcp_hangar/domain/discovery/conflict_resolver.py +267 -0
  49. mcp_hangar/domain/discovery/discovered_provider.py +185 -0
  50. mcp_hangar/domain/discovery/discovery_service.py +412 -0
  51. mcp_hangar/domain/discovery/discovery_source.py +192 -0
  52. mcp_hangar/domain/events.py +433 -0
  53. mcp_hangar/domain/exceptions.py +525 -0
  54. mcp_hangar/domain/model/__init__.py +70 -0
  55. mcp_hangar/domain/model/aggregate.py +58 -0
  56. mcp_hangar/domain/model/circuit_breaker.py +152 -0
  57. mcp_hangar/domain/model/event_sourced_api_key.py +413 -0
  58. mcp_hangar/domain/model/event_sourced_provider.py +423 -0
  59. mcp_hangar/domain/model/event_sourced_role_assignment.py +268 -0
  60. mcp_hangar/domain/model/health_tracker.py +183 -0
  61. mcp_hangar/domain/model/load_balancer.py +185 -0
  62. mcp_hangar/domain/model/provider.py +810 -0
  63. mcp_hangar/domain/model/provider_group.py +656 -0
  64. mcp_hangar/domain/model/tool_catalog.py +105 -0
  65. mcp_hangar/domain/policies/__init__.py +19 -0
  66. mcp_hangar/domain/policies/provider_health.py +187 -0
  67. mcp_hangar/domain/repository.py +249 -0
  68. mcp_hangar/domain/security/__init__.py +85 -0
  69. mcp_hangar/domain/security/input_validator.py +710 -0
  70. mcp_hangar/domain/security/rate_limiter.py +387 -0
  71. mcp_hangar/domain/security/roles.py +237 -0
  72. mcp_hangar/domain/security/sanitizer.py +387 -0
  73. mcp_hangar/domain/security/secrets.py +501 -0
  74. mcp_hangar/domain/services/__init__.py +20 -0
  75. mcp_hangar/domain/services/audit_service.py +376 -0
  76. mcp_hangar/domain/services/image_builder.py +328 -0
  77. mcp_hangar/domain/services/provider_launcher.py +1046 -0
  78. mcp_hangar/domain/value_objects.py +1138 -0
  79. mcp_hangar/errors.py +818 -0
  80. mcp_hangar/fastmcp_server.py +1105 -0
  81. mcp_hangar/gc.py +134 -0
  82. mcp_hangar/infrastructure/__init__.py +79 -0
  83. mcp_hangar/infrastructure/async_executor.py +133 -0
  84. mcp_hangar/infrastructure/auth/__init__.py +37 -0
  85. mcp_hangar/infrastructure/auth/api_key_authenticator.py +388 -0
  86. mcp_hangar/infrastructure/auth/event_sourced_store.py +567 -0
  87. mcp_hangar/infrastructure/auth/jwt_authenticator.py +360 -0
  88. mcp_hangar/infrastructure/auth/middleware.py +340 -0
  89. mcp_hangar/infrastructure/auth/opa_authorizer.py +243 -0
  90. mcp_hangar/infrastructure/auth/postgres_store.py +659 -0
  91. mcp_hangar/infrastructure/auth/projections.py +366 -0
  92. mcp_hangar/infrastructure/auth/rate_limiter.py +311 -0
  93. mcp_hangar/infrastructure/auth/rbac_authorizer.py +323 -0
  94. mcp_hangar/infrastructure/auth/sqlite_store.py +624 -0
  95. mcp_hangar/infrastructure/command_bus.py +112 -0
  96. mcp_hangar/infrastructure/discovery/__init__.py +110 -0
  97. mcp_hangar/infrastructure/discovery/docker_source.py +289 -0
  98. mcp_hangar/infrastructure/discovery/entrypoint_source.py +249 -0
  99. mcp_hangar/infrastructure/discovery/filesystem_source.py +383 -0
  100. mcp_hangar/infrastructure/discovery/kubernetes_source.py +247 -0
  101. mcp_hangar/infrastructure/event_bus.py +260 -0
  102. mcp_hangar/infrastructure/event_sourced_repository.py +443 -0
  103. mcp_hangar/infrastructure/event_store.py +396 -0
  104. mcp_hangar/infrastructure/knowledge_base/__init__.py +259 -0
  105. mcp_hangar/infrastructure/knowledge_base/contracts.py +202 -0
  106. mcp_hangar/infrastructure/knowledge_base/memory.py +177 -0
  107. mcp_hangar/infrastructure/knowledge_base/postgres.py +545 -0
  108. mcp_hangar/infrastructure/knowledge_base/sqlite.py +513 -0
  109. mcp_hangar/infrastructure/metrics_publisher.py +36 -0
  110. mcp_hangar/infrastructure/observability/__init__.py +10 -0
  111. mcp_hangar/infrastructure/observability/langfuse_adapter.py +534 -0
  112. mcp_hangar/infrastructure/persistence/__init__.py +33 -0
  113. mcp_hangar/infrastructure/persistence/audit_repository.py +371 -0
  114. mcp_hangar/infrastructure/persistence/config_repository.py +398 -0
  115. mcp_hangar/infrastructure/persistence/database.py +333 -0
  116. mcp_hangar/infrastructure/persistence/database_common.py +330 -0
  117. mcp_hangar/infrastructure/persistence/event_serializer.py +280 -0
  118. mcp_hangar/infrastructure/persistence/event_upcaster.py +166 -0
  119. mcp_hangar/infrastructure/persistence/in_memory_event_store.py +150 -0
  120. mcp_hangar/infrastructure/persistence/recovery_service.py +312 -0
  121. mcp_hangar/infrastructure/persistence/sqlite_event_store.py +386 -0
  122. mcp_hangar/infrastructure/persistence/unit_of_work.py +409 -0
  123. mcp_hangar/infrastructure/persistence/upcasters/README.md +13 -0
  124. mcp_hangar/infrastructure/persistence/upcasters/__init__.py +7 -0
  125. mcp_hangar/infrastructure/query_bus.py +153 -0
  126. mcp_hangar/infrastructure/saga_manager.py +401 -0
  127. mcp_hangar/logging_config.py +209 -0
  128. mcp_hangar/metrics.py +1007 -0
  129. mcp_hangar/models.py +31 -0
  130. mcp_hangar/observability/__init__.py +54 -0
  131. mcp_hangar/observability/health.py +487 -0
  132. mcp_hangar/observability/metrics.py +319 -0
  133. mcp_hangar/observability/tracing.py +433 -0
  134. mcp_hangar/progress.py +542 -0
  135. mcp_hangar/retry.py +613 -0
  136. mcp_hangar/server/__init__.py +120 -0
  137. mcp_hangar/server/__main__.py +6 -0
  138. mcp_hangar/server/auth_bootstrap.py +340 -0
  139. mcp_hangar/server/auth_cli.py +335 -0
  140. mcp_hangar/server/auth_config.py +305 -0
  141. mcp_hangar/server/bootstrap.py +735 -0
  142. mcp_hangar/server/cli.py +161 -0
  143. mcp_hangar/server/config.py +224 -0
  144. mcp_hangar/server/context.py +215 -0
  145. mcp_hangar/server/http_auth_middleware.py +165 -0
  146. mcp_hangar/server/lifecycle.py +467 -0
  147. mcp_hangar/server/state.py +117 -0
  148. mcp_hangar/server/tools/__init__.py +16 -0
  149. mcp_hangar/server/tools/discovery.py +186 -0
  150. mcp_hangar/server/tools/groups.py +75 -0
  151. mcp_hangar/server/tools/health.py +301 -0
  152. mcp_hangar/server/tools/provider.py +939 -0
  153. mcp_hangar/server/tools/registry.py +320 -0
  154. mcp_hangar/server/validation.py +113 -0
  155. mcp_hangar/stdio_client.py +229 -0
  156. mcp_hangar-0.2.0.dist-info/METADATA +347 -0
  157. mcp_hangar-0.2.0.dist-info/RECORD +160 -0
  158. mcp_hangar-0.2.0.dist-info/WHEEL +4 -0
  159. mcp_hangar-0.2.0.dist-info/entry_points.txt +2 -0
  160. mcp_hangar-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1138 @@
1
+ """
2
+ Value Objects for the MCP Registry domain.
3
+
4
+ Value objects are immutable, validated domain primitives that encapsulate
5
+ business rules and prevent invalid states. They replace primitive obsession
6
+ with strongly-typed domain concepts.
7
+ """
8
+
9
+ from dataclasses import dataclass
10
+ from enum import Enum
11
+ import re
12
+ from typing import Any, Dict, List, Optional
13
+ from urllib.parse import urlparse
14
+ import uuid
15
+
16
+ # --- Authentication & Authorization Value Objects ---
17
+
18
+
19
+ class PrincipalType(Enum):
20
+ """Type of authenticated principal.
21
+
22
+ Attributes:
23
+ USER: Human user authenticated via JWT/OIDC or session.
24
+ SERVICE_ACCOUNT: Non-human identity, typically authenticated via API key.
25
+ SYSTEM: Internal system principal for background operations.
26
+ """
27
+
28
+ USER = "user"
29
+ SERVICE_ACCOUNT = "service_account"
30
+ SYSTEM = "system"
31
+
32
+ def __str__(self) -> str:
33
+ return self.value
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class PrincipalId:
38
+ """Unique identifier for an authenticated principal.
39
+
40
+ PrincipalIds follow the format: [type:]identifier
41
+ Examples: "user:john@example.com", "service:ci-pipeline", "system"
42
+
43
+ Attributes:
44
+ value: The identifier string (1-256 chars, alphanumeric + -_:@.)
45
+ """
46
+
47
+ value: str
48
+
49
+ def __post_init__(self) -> None:
50
+ if not self.value:
51
+ raise ValueError("PrincipalId cannot be empty")
52
+ if len(self.value) > 256:
53
+ raise ValueError("PrincipalId must be 1-256 characters")
54
+ # Allow alphanumeric and -_:@.
55
+ allowed_chars = set("-_:@.")
56
+ if not all(c.isalnum() or c in allowed_chars for c in self.value):
57
+ raise ValueError(
58
+ f"PrincipalId contains invalid characters: {self.value!r}. " "Only alphanumeric and -_:@. are allowed."
59
+ )
60
+
61
+ def __str__(self) -> str:
62
+ return self.value
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class Principal:
67
+ """Authenticated identity making a request.
68
+
69
+ Immutable value object representing a verified identity with associated
70
+ groups and metadata. Used throughout the authorization layer.
71
+
72
+ Attributes:
73
+ id: Unique identifier for the principal.
74
+ type: Classification of the principal (user, service_account, system).
75
+ tenant_id: Optional tenant identifier for multi-tenancy.
76
+ groups: Immutable set of group memberships.
77
+ metadata: Additional identity information (email, display_name, etc.).
78
+ """
79
+
80
+ id: PrincipalId
81
+ type: PrincipalType
82
+ tenant_id: str | None = None
83
+ groups: frozenset[str] = frozenset()
84
+ metadata: dict | None = None
85
+
86
+ def __post_init__(self) -> None:
87
+ # Ensure metadata is a new dict copy to maintain immutability semantics
88
+ if self.metadata is None:
89
+ object.__setattr__(self, "metadata", {})
90
+ else:
91
+ object.__setattr__(self, "metadata", dict(self.metadata))
92
+
93
+ @classmethod
94
+ def system(cls) -> "Principal":
95
+ """Return the system principal for internal operations.
96
+
97
+ The system principal has implicit full access and is used for
98
+ background tasks, health checks, and internal operations.
99
+ """
100
+ return cls(
101
+ id=PrincipalId("system"),
102
+ type=PrincipalType.SYSTEM,
103
+ )
104
+
105
+ @classmethod
106
+ def anonymous(cls) -> "Principal":
107
+ """Return anonymous principal for unauthenticated requests.
108
+
109
+ Used when authentication is optional and no credentials provided.
110
+ """
111
+ return cls(
112
+ id=PrincipalId("anonymous"),
113
+ type=PrincipalType.USER,
114
+ )
115
+
116
+ def is_system(self) -> bool:
117
+ """Check if this is the system principal."""
118
+ return self.type == PrincipalType.SYSTEM
119
+
120
+ def is_anonymous(self) -> bool:
121
+ """Check if this is the anonymous principal."""
122
+ return self.id.value == "anonymous"
123
+
124
+ def in_group(self, group: str) -> bool:
125
+ """Check if principal is a member of the specified group."""
126
+ return group in self.groups
127
+
128
+
129
+ @dataclass(frozen=True)
130
+ class Permission:
131
+ """A specific permission that can be granted.
132
+
133
+ Permissions follow the format: resource_type:action:resource_id
134
+ Examples: "provider:read:*", "tool:invoke:math:add", "config:update:*"
135
+
136
+ Wildcard (*) matches any value for that component.
137
+
138
+ Attributes:
139
+ resource_type: Type of resource (provider, tool, config, audit, metrics).
140
+ action: Operation to perform (create, read, update, delete, invoke, list, start, stop).
141
+ resource_id: Specific resource or wildcard (*) for any.
142
+ """
143
+
144
+ resource_type: str
145
+ action: str
146
+ resource_id: str = "*"
147
+
148
+ def __post_init__(self) -> None:
149
+ if not self.resource_type:
150
+ raise ValueError("Permission resource_type cannot be empty")
151
+ if not self.action:
152
+ raise ValueError("Permission action cannot be empty")
153
+ if not self.resource_id:
154
+ raise ValueError("Permission resource_id cannot be empty")
155
+
156
+ def matches(self, resource_type: str, action: str, resource_id: str) -> bool:
157
+ """Check if this permission grants access to the requested operation.
158
+
159
+ Args:
160
+ resource_type: The type of resource being accessed.
161
+ action: The action being performed.
162
+ resource_id: The specific resource identifier.
163
+
164
+ Returns:
165
+ True if this permission grants access, False otherwise.
166
+ """
167
+ if self.resource_type != "*" and self.resource_type != resource_type:
168
+ return False
169
+ if self.action != "*" and self.action != action:
170
+ return False
171
+ if self.resource_id != "*" and self.resource_id != resource_id:
172
+ return False
173
+ return True
174
+
175
+ def __str__(self) -> str:
176
+ return f"{self.resource_type}:{self.action}:{self.resource_id}"
177
+
178
+ @classmethod
179
+ def parse(cls, permission_str: str) -> "Permission":
180
+ """Parse permission from string format 'resource:action[:id]'.
181
+
182
+ Args:
183
+ permission_str: Permission string to parse.
184
+
185
+ Returns:
186
+ Parsed Permission object.
187
+
188
+ Raises:
189
+ ValueError: If the format is invalid.
190
+
191
+ Examples:
192
+ >>> Permission.parse("provider:read")
193
+ Permission(resource_type='provider', action='read', resource_id='*')
194
+ >>> Permission.parse("tool:invoke:math:add")
195
+ Permission(resource_type='tool', action='invoke', resource_id='math:add')
196
+ """
197
+ parts = permission_str.split(":", 2) # Split into at most 3 parts
198
+ if len(parts) < 2:
199
+ raise ValueError(
200
+ f"Invalid permission format: {permission_str!r}. " "Expected 'resource:action' or 'resource:action:id'."
201
+ )
202
+ if len(parts) == 2:
203
+ return cls(resource_type=parts[0], action=parts[1])
204
+ return cls(resource_type=parts[0], action=parts[1], resource_id=parts[2])
205
+
206
+
207
+ @dataclass(frozen=True)
208
+ class Role:
209
+ """Named collection of permissions.
210
+
211
+ Roles group related permissions for easier assignment and management.
212
+ Built-in roles include: admin, provider-admin, developer, viewer, auditor.
213
+
214
+ Attributes:
215
+ name: Unique role identifier.
216
+ permissions: Immutable set of permissions granted by this role.
217
+ description: Human-readable description of the role's purpose.
218
+ """
219
+
220
+ name: str
221
+ permissions: frozenset[Permission]
222
+ description: str = ""
223
+
224
+ def __post_init__(self) -> None:
225
+ if not self.name:
226
+ raise ValueError("Role name cannot be empty")
227
+ if not self.name.replace("-", "").replace("_", "").isalnum():
228
+ raise ValueError(
229
+ f"Role name contains invalid characters: {self.name!r}. " "Only alphanumeric and -_ are allowed."
230
+ )
231
+
232
+ def has_permission(self, resource_type: str, action: str, resource_id: str = "*") -> bool:
233
+ """Check if this role grants the requested permission.
234
+
235
+ Args:
236
+ resource_type: Type of resource being accessed.
237
+ action: Operation being performed.
238
+ resource_id: Specific resource or '*' for any.
239
+
240
+ Returns:
241
+ True if any permission in this role matches the request.
242
+ """
243
+ return any(p.matches(resource_type, action, resource_id) for p in self.permissions)
244
+
245
+ def __str__(self) -> str:
246
+ return self.name
247
+
248
+
249
+ # --- Enums ---
250
+
251
+
252
+ class ProviderState(Enum):
253
+ """Provider lifecycle states.
254
+
255
+ Represents the finite state machine for provider lifecycle management.
256
+
257
+ State machine transitions:
258
+ - COLD -> INITIALIZING (on start)
259
+ - INITIALIZING -> READY (on success) | DEAD (on failure) | DEGRADED (on max failures)
260
+ - READY -> COLD (on shutdown) | DEAD (on client death) | DEGRADED (on health failures)
261
+ - DEGRADED -> INITIALIZING (on retry) | COLD (on shutdown)
262
+ - DEAD -> INITIALIZING (on retry) | DEGRADED (on max failures)
263
+
264
+ Attributes:
265
+ COLD: Provider is not running, no resources allocated.
266
+ INITIALIZING: Provider is starting up, handshake in progress.
267
+ READY: Provider is running and accepting requests.
268
+ DEGRADED: Provider has failures but may recover after backoff.
269
+ DEAD: Provider has failed fatally and requires manual intervention or retry.
270
+ """
271
+
272
+ COLD = "cold"
273
+ INITIALIZING = "initializing"
274
+ READY = "ready"
275
+ DEGRADED = "degraded"
276
+ DEAD = "dead"
277
+
278
+ def __str__(self) -> str:
279
+ """Return the string representation of the state."""
280
+ return self.value
281
+
282
+ @property
283
+ def can_accept_requests(self) -> bool:
284
+ """Check if provider can accept tool invocation requests.
285
+
286
+ Returns:
287
+ True if provider is in READY state, False otherwise.
288
+ """
289
+ return self == ProviderState.READY
290
+
291
+ @property
292
+ def can_start(self) -> bool:
293
+ """Check if provider can be started from this state.
294
+
295
+ Returns:
296
+ True if provider can transition to INITIALIZING, False otherwise.
297
+ """
298
+ return self in (ProviderState.COLD, ProviderState.DEAD, ProviderState.DEGRADED)
299
+
300
+
301
+ class HealthStatus(Enum):
302
+ """Health status for providers.
303
+
304
+ Represents the externally visible health classification of a provider.
305
+
306
+ Attributes:
307
+ HEALTHY: Provider is fully operational with no recent failures.
308
+ DEGRADED: Provider is operational but has experienced recent failures.
309
+ UNHEALTHY: Provider is not operational or has exceeded failure threshold.
310
+ UNKNOWN: Provider health cannot be determined (e.g., not started).
311
+ """
312
+
313
+ HEALTHY = "healthy"
314
+ DEGRADED = "degraded"
315
+ UNHEALTHY = "unhealthy"
316
+ UNKNOWN = "unknown"
317
+
318
+ def __str__(self) -> str:
319
+ """Return the string representation of the status."""
320
+ return self.value
321
+
322
+ @classmethod
323
+ def from_state(cls, state: ProviderState, consecutive_failures: int = 0) -> "HealthStatus":
324
+ """Derive health status from provider state and failure count.
325
+
326
+ Args:
327
+ state: The current provider state.
328
+ consecutive_failures: Number of consecutive failures (default: 0).
329
+
330
+ Returns:
331
+ The derived HealthStatus based on state and failures.
332
+
333
+ Example:
334
+ >>> HealthStatus.from_state(ProviderState.READY, 0)
335
+ <HealthStatus.HEALTHY: 'healthy'>
336
+ """
337
+ if state == ProviderState.READY:
338
+ if consecutive_failures == 0:
339
+ return cls.HEALTHY
340
+ return cls.DEGRADED
341
+ elif state == ProviderState.DEGRADED:
342
+ return cls.UNHEALTHY
343
+ elif state == ProviderState.COLD:
344
+ return cls.UNKNOWN
345
+ else:
346
+ return cls.UNHEALTHY
347
+
348
+
349
+ class ProviderMode(Enum):
350
+ """Mode for running a provider."""
351
+
352
+ SUBPROCESS = "subprocess"
353
+ DOCKER = "docker"
354
+ CONTAINER = "container" # Alias for docker mode
355
+ REMOTE = "remote"
356
+ GROUP = "group" # Provider group with load balancing
357
+
358
+ def __str__(self) -> str:
359
+ return self.value
360
+
361
+ @classmethod
362
+ def normalize(cls, value: "str | ProviderMode") -> "ProviderMode":
363
+ """Normalize mode value to ProviderMode enum."""
364
+ if isinstance(value, cls):
365
+ return value
366
+ # Handle string values - return corresponding enum
367
+ return cls(value)
368
+
369
+
370
+ class LoadBalancerStrategy(Enum):
371
+ """Load balancing strategy for provider groups."""
372
+
373
+ ROUND_ROBIN = "round_robin"
374
+ WEIGHTED_ROUND_ROBIN = "weighted_round_robin"
375
+ LEAST_CONNECTIONS = "least_connections"
376
+ RANDOM = "random"
377
+ PRIORITY = "priority" # Always prefer lowest priority member
378
+
379
+ def __str__(self) -> str:
380
+ return self.value
381
+
382
+
383
+ class GroupState(Enum):
384
+ """Provider group lifecycle states."""
385
+
386
+ INACTIVE = "inactive" # No members started
387
+ PARTIAL = "partial" # Some members healthy, below min_healthy
388
+ HEALTHY = "healthy" # >= min_healthy members ready
389
+ DEGRADED = "degraded" # Circuit breaker tripped
390
+
391
+ def __str__(self) -> str:
392
+ return self.value
393
+
394
+ @property
395
+ def can_accept_requests(self) -> bool:
396
+ """Check if group can accept tool invocation requests."""
397
+ return self in (GroupState.HEALTHY, GroupState.PARTIAL)
398
+
399
+
400
+ # --- Identity Value Objects ---
401
+
402
+
403
+ class ProviderId:
404
+ """Unique identifier for a provider.
405
+
406
+ Validates and encapsulates provider identity with strict rules:
407
+ - Non-empty string
408
+ - Alphanumeric, hyphens, underscores only
409
+ - Max 64 characters
410
+
411
+ Attributes:
412
+ value: The validated provider identifier string.
413
+
414
+ Raises:
415
+ ValueError: If the provided value violates validation rules.
416
+
417
+ Example:
418
+ >>> provider_id = ProviderId("my-provider-1")
419
+ >>> str(provider_id)
420
+ 'my-provider-1'
421
+ """
422
+
423
+ _VALID_PATTERN = re.compile(r"^[a-zA-Z0-9_-]+$")
424
+ _MAX_LENGTH = 64
425
+
426
+ def __init__(self, value: str):
427
+ """Initialize ProviderId with validation.
428
+
429
+ Args:
430
+ value: The provider identifier string to validate.
431
+
432
+ Raises:
433
+ ValueError: If value is empty, too long, or contains invalid characters.
434
+ """
435
+ if not value:
436
+ raise ValueError("ProviderId cannot be empty")
437
+ if len(value) > self._MAX_LENGTH:
438
+ raise ValueError(f"ProviderId cannot exceed {self._MAX_LENGTH} characters")
439
+ if not self._VALID_PATTERN.match(value):
440
+ raise ValueError("ProviderId must contain only alphanumeric characters, hyphens, and underscores")
441
+ self._value = value
442
+
443
+ @property
444
+ def value(self) -> str:
445
+ """Get the raw identifier string."""
446
+ return self._value
447
+
448
+ def __str__(self) -> str:
449
+ return self._value
450
+
451
+ def __repr__(self) -> str:
452
+ return f"ProviderId('{self._value}')"
453
+
454
+ def __eq__(self, other) -> bool:
455
+ if isinstance(other, str):
456
+ return self._value == other
457
+ if not isinstance(other, ProviderId):
458
+ return False
459
+ return self._value == other._value
460
+
461
+ def __hash__(self) -> int:
462
+ return hash(self._value)
463
+
464
+
465
+ class ToolName:
466
+ """Name of a tool provided by a provider.
467
+
468
+ Validates tool names with the following rules:
469
+ - Non-empty string
470
+ - Alphanumeric, hyphens, underscores, dots allowed (for namespaced tools)
471
+ - Max 128 characters
472
+
473
+ Attributes:
474
+ value: The validated tool name string.
475
+
476
+ Raises:
477
+ ValueError: If the provided value violates validation rules.
478
+
479
+ Example:
480
+ >>> tool = ToolName("math.add")
481
+ >>> str(tool)
482
+ 'math.add'
483
+ """
484
+
485
+ _VALID_PATTERN = re.compile(r"^[a-zA-Z0-9_.\-]+$")
486
+ _MAX_LENGTH = 128
487
+
488
+ def __init__(self, value: str):
489
+ """Initialize ToolName with validation.
490
+
491
+ Args:
492
+ value: The tool name string to validate.
493
+
494
+ Raises:
495
+ ValueError: If value is empty, too long, or contains invalid characters.
496
+ """
497
+ if not value:
498
+ raise ValueError("ToolName cannot be empty")
499
+ if len(value) > self._MAX_LENGTH:
500
+ raise ValueError(f"ToolName cannot exceed {self._MAX_LENGTH} characters")
501
+ if not self._VALID_PATTERN.match(value):
502
+ raise ValueError("ToolName must contain only alphanumeric characters, hyphens, underscores, and dots")
503
+ self._value = value
504
+
505
+ @property
506
+ def value(self) -> str:
507
+ """Get the raw tool name string."""
508
+ return self._value
509
+
510
+ def __str__(self) -> str:
511
+ return self._value
512
+
513
+ def __repr__(self) -> str:
514
+ return f"ToolName('{self._value}')"
515
+
516
+ def __eq__(self, other) -> bool:
517
+ if isinstance(other, str):
518
+ return self._value == other
519
+ if not isinstance(other, ToolName):
520
+ return False
521
+ return self._value == other._value
522
+
523
+ def __hash__(self) -> int:
524
+ return hash(self._value)
525
+
526
+
527
+ @dataclass(frozen=True)
528
+ class CorrelationId:
529
+ """Correlation ID for tracing requests.
530
+
531
+ Rules:
532
+ - Non-empty string
533
+ - Valid UUID v4 format (or auto-generated)
534
+ """
535
+
536
+ value: str
537
+
538
+ def __init__(self, value: Optional[str] = None):
539
+ if value is None:
540
+ # Generate new UUID
541
+ value = str(uuid.uuid4())
542
+ else:
543
+ # Validate existing UUID
544
+ if not value:
545
+ raise ValueError("CorrelationId cannot be empty")
546
+ try:
547
+ uuid.UUID(value, version=4)
548
+ except ValueError:
549
+ raise ValueError("CorrelationId must be a valid UUID v4")
550
+
551
+ object.__setattr__(self, "value", value)
552
+
553
+ def __str__(self) -> str:
554
+ return self.value
555
+
556
+ def __repr__(self) -> str:
557
+ return f"CorrelationId('{self.value}')"
558
+
559
+
560
+ # --- Configuration Value Objects ---
561
+
562
+
563
+ @dataclass(frozen=True)
564
+ class CommandLine:
565
+ """Command line with arguments for subprocess providers.
566
+
567
+ Rules:
568
+ - Non-empty command list
569
+ - First element is the command/executable
570
+ - Remaining elements are arguments
571
+ """
572
+
573
+ command: str
574
+ arguments: tuple
575
+
576
+ def __init__(self, command: str, *arguments: str):
577
+ if not command:
578
+ raise ValueError("Command cannot be empty")
579
+ # Use object.__setattr__ because dataclass is frozen
580
+ object.__setattr__(self, "command", command)
581
+ object.__setattr__(self, "arguments", tuple(arguments))
582
+
583
+ @classmethod
584
+ def from_list(cls, command_list: List[str]) -> "CommandLine":
585
+ """Create from a list of strings."""
586
+ if not command_list:
587
+ raise ValueError("Command list cannot be empty")
588
+ return cls(command_list[0], *command_list[1:])
589
+
590
+ def to_list(self) -> List[str]:
591
+ """Convert to list format."""
592
+ return [self.command, *self.arguments]
593
+
594
+ def __str__(self) -> str:
595
+ return " ".join(self.to_list())
596
+
597
+
598
+ @dataclass(frozen=True)
599
+ class DockerImage:
600
+ """Docker image specification.
601
+
602
+ Rules:
603
+ - Non-empty string
604
+ - Valid docker image format (name:tag or registry/name:tag)
605
+ """
606
+
607
+ value: str
608
+
609
+ def __init__(self, value: str):
610
+ if not value:
611
+ raise ValueError("DockerImage cannot be empty")
612
+ # Basic validation - could be more sophisticated
613
+ if not re.match(r"^[\w.\-/:]+$", value):
614
+ raise ValueError("Invalid docker image format")
615
+ object.__setattr__(self, "value", value)
616
+
617
+ @property
618
+ def name(self) -> str:
619
+ """Extract image name without tag."""
620
+ return self.value.split(":")[0]
621
+
622
+ @property
623
+ def tag(self) -> str:
624
+ """Extract tag, defaults to 'latest'."""
625
+ parts = self.value.split(":")
626
+ return parts[1] if len(parts) > 1 else "latest"
627
+
628
+ def __str__(self) -> str:
629
+ return self.value
630
+
631
+
632
+ @dataclass(frozen=True)
633
+ class Endpoint:
634
+ """Remote endpoint URL.
635
+
636
+ Rules:
637
+ - Non-empty string
638
+ - Valid URL format
639
+ - Supported schemes: http, https, ws, wss
640
+ """
641
+
642
+ value: str
643
+
644
+ def __init__(self, value: str):
645
+ if not value:
646
+ raise ValueError("Endpoint cannot be empty")
647
+
648
+ parsed = urlparse(value)
649
+
650
+ # Check for valid scheme - urlparse treats "localhost:8080" as scheme="localhost"
651
+ # If netloc is empty and path exists, it's likely missing scheme (e.g., "localhost:8080")
652
+ if not parsed.netloc and parsed.path:
653
+ raise ValueError("Endpoint must include scheme (http, https, ws, wss)")
654
+
655
+ # Check that we have a host
656
+ if not parsed.netloc:
657
+ raise ValueError("Endpoint must include host")
658
+
659
+ # Validate scheme
660
+ if parsed.scheme not in ["http", "https", "ws", "wss"]:
661
+ raise ValueError(f"Unsupported endpoint scheme: {parsed.scheme}")
662
+
663
+ object.__setattr__(self, "value", value)
664
+
665
+ @property
666
+ def scheme(self) -> str:
667
+ return urlparse(self.value).scheme
668
+
669
+ @property
670
+ def host(self) -> str:
671
+ return urlparse(self.value).netloc
672
+
673
+ def __str__(self) -> str:
674
+ return self.value
675
+
676
+
677
+ @dataclass(frozen=True)
678
+ class EnvironmentVariables:
679
+ """Environment variables for provider execution.
680
+
681
+ Rules:
682
+ - Keys are non-empty strings
683
+ - Values are strings
684
+ - Immutable after creation
685
+ """
686
+
687
+ variables: Dict[str, str]
688
+
689
+ def __init__(self, variables: Optional[Dict[str, str]] = None):
690
+ vars_dict = variables or {}
691
+
692
+ # Validate keys
693
+ for key in vars_dict.keys():
694
+ if not key:
695
+ raise ValueError("Environment variable key cannot be empty")
696
+ if not isinstance(key, str):
697
+ raise ValueError("Environment variable key must be a string")
698
+
699
+ # Create immutable copy
700
+ object.__setattr__(self, "variables", dict(vars_dict))
701
+
702
+ def get(self, key: str, default: Optional[str] = None) -> Optional[str]:
703
+ """Get environment variable value."""
704
+ return self.variables.get(key, default)
705
+
706
+ def __getitem__(self, key: str) -> str:
707
+ return self.variables[key]
708
+
709
+ def __contains__(self, key: str) -> bool:
710
+ return key in self.variables
711
+
712
+ def __len__(self) -> int:
713
+ return len(self.variables)
714
+
715
+ def to_dict(self) -> Dict[str, str]:
716
+ """Convert to dictionary (returns a copy)."""
717
+ return dict(self.variables)
718
+
719
+
720
+ # --- Timing Value Objects ---
721
+
722
+
723
+ @dataclass(frozen=True)
724
+ class IdleTTL:
725
+ """Time-to-live for idle providers in seconds.
726
+
727
+ Rules:
728
+ - Positive integer
729
+ - Reasonable range: 1 to 86400 seconds (1 day)
730
+ """
731
+
732
+ seconds: int
733
+
734
+ def __init__(self, seconds: int):
735
+ if seconds <= 0:
736
+ raise ValueError("IdleTTL must be positive")
737
+ if seconds > 86400:
738
+ raise ValueError("IdleTTL cannot exceed 86400 seconds (1 day)")
739
+ object.__setattr__(self, "seconds", seconds)
740
+
741
+ def __int__(self) -> int:
742
+ return self.seconds
743
+
744
+ def __str__(self) -> str:
745
+ return f"{self.seconds}s"
746
+
747
+
748
+ @dataclass(frozen=True)
749
+ class HealthCheckInterval:
750
+ """Interval between health checks in seconds.
751
+
752
+ Rules:
753
+ - Positive integer
754
+ - Reasonable range: 5 to 3600 seconds (1 hour)
755
+ """
756
+
757
+ seconds: int
758
+
759
+ def __init__(self, seconds: int):
760
+ if seconds <= 0:
761
+ raise ValueError("HealthCheckInterval must be positive")
762
+ if seconds < 5:
763
+ raise ValueError("HealthCheckInterval must be at least 5 seconds")
764
+ if seconds > 3600:
765
+ raise ValueError("HealthCheckInterval cannot exceed 3600 seconds (1 hour)")
766
+ object.__setattr__(self, "seconds", seconds)
767
+
768
+ def __int__(self) -> int:
769
+ return self.seconds
770
+
771
+ def __str__(self) -> str:
772
+ return f"{self.seconds}s"
773
+
774
+
775
+ @dataclass(frozen=True)
776
+ class MaxConsecutiveFailures:
777
+ """Maximum consecutive failures before degradation.
778
+
779
+ Rules:
780
+ - Positive integer
781
+ - Reasonable range: 1 to 100
782
+ """
783
+
784
+ count: int
785
+
786
+ def __init__(self, count: int):
787
+ if count <= 0:
788
+ raise ValueError("MaxConsecutiveFailures must be positive")
789
+ if count > 100:
790
+ raise ValueError("MaxConsecutiveFailures cannot exceed 100")
791
+ object.__setattr__(self, "count", count)
792
+
793
+ def __int__(self) -> int:
794
+ return self.count
795
+
796
+ def __str__(self) -> str:
797
+ return str(self.count)
798
+
799
+
800
+ @dataclass(frozen=True)
801
+ class TimeoutSeconds:
802
+ """Timeout duration in seconds.
803
+
804
+ Rules:
805
+ - Positive number (int or float)
806
+ - Reasonable range: 0.1 to 3600 seconds
807
+ """
808
+
809
+ seconds: float
810
+
811
+ def __init__(self, seconds: float):
812
+ if seconds <= 0:
813
+ raise ValueError("TimeoutSeconds must be positive")
814
+ if seconds > 3600:
815
+ raise ValueError("TimeoutSeconds cannot exceed 3600 seconds (1 hour)")
816
+ object.__setattr__(self, "seconds", float(seconds))
817
+
818
+ def __float__(self) -> float:
819
+ return self.seconds
820
+
821
+ def __str__(self) -> str:
822
+ return f"{self.seconds}s"
823
+
824
+
825
+ # --- Provider Configuration ---
826
+
827
+
828
+ @dataclass(frozen=True)
829
+ class ProviderConfig:
830
+ """Complete configuration for a provider.
831
+
832
+ Encapsulates all configuration options in a validated, immutable object.
833
+ """
834
+
835
+ provider_id: ProviderId
836
+ mode: ProviderMode
837
+ command: Optional[CommandLine] = None
838
+ image: Optional[DockerImage] = None
839
+ endpoint: Optional[Endpoint] = None
840
+ env: Optional[EnvironmentVariables] = None
841
+ idle_ttl: IdleTTL = None
842
+ health_check_interval: HealthCheckInterval = None
843
+ max_consecutive_failures: MaxConsecutiveFailures = None
844
+
845
+ def __init__(
846
+ self,
847
+ provider_id: str,
848
+ mode: str,
849
+ command: Optional[List[str]] = None,
850
+ image: Optional[str] = None,
851
+ endpoint: Optional[str] = None,
852
+ env: Optional[Dict[str, str]] = None,
853
+ idle_ttl_s: int = 300,
854
+ health_check_interval_s: int = 60,
855
+ max_consecutive_failures: int = 3,
856
+ ):
857
+ # Validate and convert provider_id
858
+ object.__setattr__(self, "provider_id", ProviderId(provider_id))
859
+
860
+ # Validate and convert mode
861
+ try:
862
+ object.__setattr__(self, "mode", ProviderMode(mode))
863
+ except ValueError:
864
+ raise ValueError(f"Invalid provider mode: {mode}. Must be one of: subprocess, docker, remote")
865
+
866
+ # Validate mode-specific configuration
867
+ resolved_mode = ProviderMode(mode)
868
+
869
+ if resolved_mode == ProviderMode.SUBPROCESS:
870
+ if not command:
871
+ raise ValueError("Subprocess mode requires 'command' configuration")
872
+ object.__setattr__(self, "command", CommandLine.from_list(command))
873
+ object.__setattr__(self, "image", None)
874
+ object.__setattr__(self, "endpoint", None)
875
+ elif resolved_mode == ProviderMode.DOCKER:
876
+ if not image:
877
+ raise ValueError("Docker mode requires 'image' configuration")
878
+ object.__setattr__(self, "command", None)
879
+ object.__setattr__(self, "image", DockerImage(image))
880
+ object.__setattr__(self, "endpoint", None)
881
+ elif resolved_mode == ProviderMode.REMOTE:
882
+ if not endpoint:
883
+ raise ValueError("Remote mode requires 'endpoint' configuration")
884
+ object.__setattr__(self, "command", None)
885
+ object.__setattr__(self, "image", None)
886
+ object.__setattr__(self, "endpoint", Endpoint(endpoint))
887
+
888
+ # Environment variables
889
+ object.__setattr__(self, "env", EnvironmentVariables(env) if env else EnvironmentVariables())
890
+
891
+ # Timing configuration
892
+ object.__setattr__(self, "idle_ttl", IdleTTL(idle_ttl_s))
893
+ object.__setattr__(self, "health_check_interval", HealthCheckInterval(health_check_interval_s))
894
+ object.__setattr__(
895
+ self,
896
+ "max_consecutive_failures",
897
+ MaxConsecutiveFailures(max_consecutive_failures),
898
+ )
899
+
900
+ def to_dict(self) -> Dict[str, Any]:
901
+ """Convert to dictionary representation."""
902
+ result = {
903
+ "provider_id": str(self.provider_id),
904
+ "mode": str(self.mode),
905
+ "idle_ttl_s": self.idle_ttl.seconds,
906
+ "health_check_interval_s": self.health_check_interval.seconds,
907
+ "max_consecutive_failures": self.max_consecutive_failures.count,
908
+ }
909
+
910
+ if self.command:
911
+ result["command"] = self.command.to_list()
912
+ if self.image:
913
+ result["image"] = str(self.image)
914
+ if self.endpoint:
915
+ result["endpoint"] = str(self.endpoint)
916
+ if self.env and len(self.env) > 0:
917
+ result["env"] = self.env.to_dict()
918
+
919
+ return result
920
+
921
+
922
+ # --- Tool Arguments ---
923
+
924
+
925
+ class ToolArguments:
926
+ """Validated tool invocation arguments.
927
+
928
+ Rules:
929
+ - Must be a dictionary
930
+ - Size limited to prevent DoS
931
+ - Keys must be strings
932
+ """
933
+
934
+ MAX_SIZE_BYTES = 1_000_000 # 1MB limit
935
+ MAX_DEPTH = 10 # Maximum nesting depth
936
+
937
+ def __init__(self, arguments: Dict[str, Any]):
938
+ if not isinstance(arguments, dict):
939
+ raise ValueError("Tool arguments must be a dictionary")
940
+
941
+ self._validate_size(arguments)
942
+ self._validate_structure(arguments)
943
+ self._arguments = arguments
944
+
945
+ def _validate_size(self, arguments: Dict[str, Any]) -> None:
946
+ """Validate arguments don't exceed size limit."""
947
+ import json
948
+
949
+ try:
950
+ size = len(json.dumps(arguments))
951
+ if size > self.MAX_SIZE_BYTES:
952
+ raise ValueError(f"Tool arguments exceed maximum size ({size} > {self.MAX_SIZE_BYTES} bytes)")
953
+ except (TypeError, ValueError) as e:
954
+ if "size" not in str(e):
955
+ raise ValueError(f"Tool arguments must be JSON-serializable: {e}")
956
+ raise
957
+
958
+ def _validate_structure(self, obj: Any, depth: int = 0) -> None:
959
+ """Validate argument structure and depth."""
960
+ if depth > self.MAX_DEPTH:
961
+ raise ValueError(f"Tool arguments exceed maximum nesting depth ({self.MAX_DEPTH})")
962
+
963
+ if isinstance(obj, dict):
964
+ for key, value in obj.items():
965
+ if not isinstance(key, str):
966
+ raise ValueError("Tool argument keys must be strings")
967
+ self._validate_structure(value, depth + 1)
968
+ elif isinstance(obj, list):
969
+ for item in obj:
970
+ self._validate_structure(item, depth + 1)
971
+
972
+ @property
973
+ def value(self) -> Dict[str, Any]:
974
+ """Get the validated arguments."""
975
+ return self._arguments
976
+
977
+ def to_dict(self) -> Dict[str, Any]:
978
+ """Convert to dictionary (returns a copy)."""
979
+ return dict(self._arguments)
980
+
981
+ def __getitem__(self, key: str) -> Any:
982
+ return self._arguments[key]
983
+
984
+ def __contains__(self, key: str) -> bool:
985
+ return key in self._arguments
986
+
987
+ def get(self, key: str, default: Any = None) -> Any:
988
+ return self._arguments.get(key, default)
989
+
990
+
991
+ # --- Group-related Value Objects ---
992
+
993
+
994
+ class GroupId:
995
+ """Unique identifier for a provider group.
996
+
997
+ Rules:
998
+ - Same rules as ProviderId
999
+ - Non-empty string
1000
+ - Alphanumeric, hyphens, underscores only
1001
+ - Max 64 characters
1002
+ """
1003
+
1004
+ _VALID_PATTERN = re.compile(r"^[a-zA-Z0-9_-]+$")
1005
+ _MAX_LENGTH = 64
1006
+
1007
+ def __init__(self, value: str):
1008
+ if not value:
1009
+ raise ValueError("GroupId cannot be empty")
1010
+ if len(value) > self._MAX_LENGTH:
1011
+ raise ValueError(f"GroupId cannot exceed {self._MAX_LENGTH} characters")
1012
+ if not self._VALID_PATTERN.match(value):
1013
+ raise ValueError("GroupId must contain only alphanumeric characters, hyphens, and underscores")
1014
+ self._value = value
1015
+
1016
+ @property
1017
+ def value(self) -> str:
1018
+ return self._value
1019
+
1020
+ def __str__(self) -> str:
1021
+ return self._value
1022
+
1023
+ def __repr__(self) -> str:
1024
+ return f"GroupId('{self._value}')"
1025
+
1026
+ def __eq__(self, other) -> bool:
1027
+ if isinstance(other, str):
1028
+ return self._value == other
1029
+ if not isinstance(other, GroupId):
1030
+ return False
1031
+ return self._value == other._value
1032
+
1033
+ def __hash__(self) -> int:
1034
+ return hash(self._value)
1035
+
1036
+
1037
+ class MemberWeight:
1038
+ """Weight for a group member in weighted load balancing.
1039
+
1040
+ Rules:
1041
+ - Positive integer (>= 1)
1042
+ - Max value: 100
1043
+ - Higher weight = more traffic
1044
+ """
1045
+
1046
+ MIN_WEIGHT = 1
1047
+ MAX_WEIGHT = 100
1048
+
1049
+ def __init__(self, value: int = 1):
1050
+ if not isinstance(value, int):
1051
+ raise ValueError("MemberWeight must be an integer")
1052
+ if value < self.MIN_WEIGHT:
1053
+ raise ValueError(f"MemberWeight must be at least {self.MIN_WEIGHT}")
1054
+ if value > self.MAX_WEIGHT:
1055
+ raise ValueError(f"MemberWeight cannot exceed {self.MAX_WEIGHT}")
1056
+ self._value = value
1057
+
1058
+ @property
1059
+ def value(self) -> int:
1060
+ return self._value
1061
+
1062
+ def __int__(self) -> int:
1063
+ return self._value
1064
+
1065
+ def __str__(self) -> str:
1066
+ return str(self._value)
1067
+
1068
+ def __repr__(self) -> str:
1069
+ return f"MemberWeight({self._value})"
1070
+
1071
+ def __eq__(self, other) -> bool:
1072
+ if isinstance(other, int):
1073
+ return self._value == other
1074
+ if not isinstance(other, MemberWeight):
1075
+ return False
1076
+ return self._value == other._value
1077
+
1078
+ def __hash__(self) -> int:
1079
+ return hash(self._value)
1080
+
1081
+ def __lt__(self, other) -> bool:
1082
+ if isinstance(other, int):
1083
+ return self._value < other
1084
+ if isinstance(other, MemberWeight):
1085
+ return self._value < other._value
1086
+ return NotImplemented
1087
+
1088
+
1089
+ class MemberPriority:
1090
+ """Priority for a group member in priority-based selection.
1091
+
1092
+ Rules:
1093
+ - Positive integer (>= 1)
1094
+ - Lower value = higher priority (1 is highest)
1095
+ - Max value: 100
1096
+ """
1097
+
1098
+ MIN_PRIORITY = 1
1099
+ MAX_PRIORITY = 100
1100
+
1101
+ def __init__(self, value: int = 1):
1102
+ if not isinstance(value, int):
1103
+ raise ValueError("MemberPriority must be an integer")
1104
+ if value < self.MIN_PRIORITY:
1105
+ raise ValueError(f"MemberPriority must be at least {self.MIN_PRIORITY}")
1106
+ if value > self.MAX_PRIORITY:
1107
+ raise ValueError(f"MemberPriority cannot exceed {self.MAX_PRIORITY}")
1108
+ self._value = value
1109
+
1110
+ @property
1111
+ def value(self) -> int:
1112
+ return self._value
1113
+
1114
+ def __int__(self) -> int:
1115
+ return self._value
1116
+
1117
+ def __str__(self) -> str:
1118
+ return str(self._value)
1119
+
1120
+ def __repr__(self) -> str:
1121
+ return f"MemberPriority({self._value})"
1122
+
1123
+ def __eq__(self, other) -> bool:
1124
+ if isinstance(other, int):
1125
+ return self._value == other
1126
+ if not isinstance(other, MemberPriority):
1127
+ return False
1128
+ return self._value == other._value
1129
+
1130
+ def __hash__(self) -> int:
1131
+ return hash(self._value)
1132
+
1133
+ def __lt__(self, other) -> bool:
1134
+ if isinstance(other, int):
1135
+ return self._value < other
1136
+ if isinstance(other, MemberPriority):
1137
+ return self._value < other._value
1138
+ return NotImplemented