tyler-ag-tapper 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.
@@ -0,0 +1,18 @@
1
+ """Registry module for service discovery."""
2
+
3
+ from tapper.registry.backends import InMemoryBackend, RegistryBackend
4
+ from tapper.registry.client import RegistryClient
5
+ from tapper.registry.server import create_registry_app
6
+
7
+ __all__ = [
8
+ "RegistryBackend",
9
+ "InMemoryBackend",
10
+ "RegistryClient",
11
+ "create_registry_app",
12
+ ]
13
+
14
+ try:
15
+ from tapper.registry.backends import RedisBackend
16
+ __all__.append("RedisBackend")
17
+ except ImportError:
18
+ pass
@@ -0,0 +1,12 @@
1
+ """Registry backend implementations."""
2
+
3
+ from tapper.registry.backends.base import RegistryBackend
4
+ from tapper.registry.backends.memory import InMemoryBackend
5
+
6
+ __all__ = ["RegistryBackend", "InMemoryBackend"]
7
+
8
+ try:
9
+ from tapper.registry.backends.redis import RedisBackend
10
+ __all__.append("RedisBackend")
11
+ except ImportError:
12
+ pass
@@ -0,0 +1,43 @@
1
+ """Abstract base class for registry backends."""
2
+
3
+ from abc import ABC, abstractmethod
4
+
5
+ from tapper.models import ServiceInfo, ServiceInstance
6
+
7
+
8
+ class RegistryBackend(ABC):
9
+ """Abstract base class for service registry backends."""
10
+
11
+ @abstractmethod
12
+ async def register(self, service: ServiceInfo, instance: ServiceInstance) -> None:
13
+ """Register a service instance.
14
+
15
+ If the service already exists, adds the instance to it.
16
+ If the instance already exists, updates its information.
17
+ """
18
+ pass
19
+
20
+ @abstractmethod
21
+ async def unregister(self, name: str, instance_url: str) -> None:
22
+ """Remove a service instance from the registry."""
23
+ pass
24
+
25
+ @abstractmethod
26
+ async def get_service(self, name: str) -> ServiceInfo | None:
27
+ """Get a service by name, or None if not found."""
28
+ pass
29
+
30
+ @abstractmethod
31
+ async def get_all_services(self) -> list[ServiceInfo]:
32
+ """Get all registered services."""
33
+ pass
34
+
35
+ @abstractmethod
36
+ async def heartbeat(self, name: str, instance_url: str) -> None:
37
+ """Update the heartbeat timestamp for a service instance."""
38
+ pass
39
+
40
+ @abstractmethod
41
+ async def cleanup_stale(self, max_age_seconds: int = 60) -> None:
42
+ """Remove instances that haven't sent a heartbeat within max_age_seconds."""
43
+ pass
@@ -0,0 +1,93 @@
1
+ """In-memory registry backend for development and testing."""
2
+
3
+ import asyncio
4
+ from datetime import UTC, datetime, timedelta
5
+
6
+ from tapper.models import ServiceInfo, ServiceInstance
7
+ from tapper.registry.backends.base import RegistryBackend
8
+
9
+
10
+ class InMemoryBackend(RegistryBackend):
11
+ """In-memory implementation of the registry backend.
12
+
13
+ Suitable for development and single-instance deployments.
14
+ Data is lost when the process stops.
15
+ """
16
+
17
+ def __init__(self) -> None:
18
+ self._services: dict[str, ServiceInfo] = {}
19
+ self._lock = asyncio.Lock()
20
+
21
+ async def register(self, service: ServiceInfo, instance: ServiceInstance) -> None:
22
+ """Register a service instance."""
23
+ async with self._lock:
24
+ if service.name in self._services:
25
+ existing = self._services[service.name]
26
+ instance_urls = {inst.url for inst in existing.instances}
27
+ if instance.url in instance_urls:
28
+ existing.instances = [
29
+ instance if inst.url == instance.url else inst
30
+ for inst in existing.instances
31
+ ]
32
+ else:
33
+ existing.instances.append(instance)
34
+ existing.routes = service.routes
35
+ existing.version = service.version
36
+ existing.description = service.description
37
+ existing.prefix = service.prefix
38
+ existing.tags = service.tags
39
+ else:
40
+ service_copy = service.model_copy(deep=True)
41
+ service_copy.instances = [instance]
42
+ self._services[service.name] = service_copy
43
+
44
+ async def unregister(self, name: str, instance_url: str) -> None:
45
+ """Remove a service instance from the registry."""
46
+ async with self._lock:
47
+ if name in self._services:
48
+ service = self._services[name]
49
+ service.instances = [
50
+ inst for inst in service.instances if inst.url != instance_url
51
+ ]
52
+ if not service.instances:
53
+ del self._services[name]
54
+
55
+ async def get_service(self, name: str) -> ServiceInfo | None:
56
+ """Get a service by name."""
57
+ async with self._lock:
58
+ service = self._services.get(name)
59
+ if service:
60
+ return service.model_copy(deep=True)
61
+ return None
62
+
63
+ async def get_all_services(self) -> list[ServiceInfo]:
64
+ """Get all registered services."""
65
+ async with self._lock:
66
+ return [svc.model_copy(deep=True) for svc in self._services.values()]
67
+
68
+ async def heartbeat(self, name: str, instance_url: str) -> None:
69
+ """Update the heartbeat timestamp for a service instance."""
70
+ async with self._lock:
71
+ if name in self._services:
72
+ for instance in self._services[name].instances:
73
+ if instance.url == instance_url:
74
+ instance.last_heartbeat = datetime.now(UTC)
75
+ instance.healthy = True
76
+ break
77
+
78
+ async def cleanup_stale(self, max_age_seconds: int = 60) -> None:
79
+ """Remove instances that haven't sent a heartbeat within max_age_seconds."""
80
+ async with self._lock:
81
+ cutoff = datetime.now(UTC) - timedelta(seconds=max_age_seconds)
82
+ services_to_remove = []
83
+
84
+ for name, service in self._services.items():
85
+ service.instances = [
86
+ inst for inst in service.instances
87
+ if inst.last_heartbeat > cutoff
88
+ ]
89
+ if not service.instances:
90
+ services_to_remove.append(name)
91
+
92
+ for name in services_to_remove:
93
+ del self._services[name]
@@ -0,0 +1,163 @@
1
+ """Redis-based registry backend for distributed deployments."""
2
+
3
+ import hashlib
4
+ import json
5
+ from datetime import UTC, datetime, timedelta
6
+
7
+ from tapper.models import ServiceInfo, ServiceInstance
8
+ from tapper.registry.backends.base import RegistryBackend
9
+
10
+ try:
11
+ import redis.asyncio as redis
12
+ except ImportError:
13
+ redis = None # type: ignore
14
+
15
+
16
+ class RedisBackend(RegistryBackend):
17
+ """Redis-based implementation of the registry backend.
18
+
19
+ Suitable for distributed deployments where multiple registry instances
20
+ need to share state.
21
+
22
+ Requires the 'redis' optional dependency: pip install tapper[redis]
23
+ """
24
+
25
+ SERVICE_KEY_PREFIX = "tapper:service:"
26
+ INSTANCE_KEY_PREFIX = "tapper:instance:"
27
+ DEFAULT_INSTANCE_TTL = 90 # seconds
28
+
29
+ def __init__(
30
+ self,
31
+ redis_url: str = "redis://localhost:6379",
32
+ instance_ttl: int = DEFAULT_INSTANCE_TTL,
33
+ ) -> None:
34
+ if redis is None:
35
+ raise ImportError(
36
+ "Redis support requires the 'redis' package. "
37
+ "Install with: pip install tapper[redis]"
38
+ )
39
+ self._redis_url = redis_url
40
+ self._instance_ttl = instance_ttl
41
+ self._client: redis.Redis | None = None
42
+
43
+ async def _get_client(self) -> "redis.Redis":
44
+ """Get or create the Redis client."""
45
+ if self._client is None:
46
+ self._client = redis.from_url(self._redis_url)
47
+ return self._client
48
+
49
+ def _service_key(self, name: str) -> str:
50
+ """Generate Redis key for service info."""
51
+ return f"{self.SERVICE_KEY_PREFIX}{name}"
52
+
53
+ def _instance_key(self, name: str, url: str) -> str:
54
+ """Generate Redis key for service instance."""
55
+ url_hash = hashlib.md5(url.encode()).hexdigest()[:12]
56
+ return f"{self.INSTANCE_KEY_PREFIX}{name}:{url_hash}"
57
+
58
+ async def register(self, service: ServiceInfo, instance: ServiceInstance) -> None:
59
+ """Register a service instance."""
60
+ client = await self._get_client()
61
+
62
+ service_data = service.model_dump(mode="json")
63
+ service_data["instances"] = []
64
+ await client.set(
65
+ self._service_key(service.name),
66
+ json.dumps(service_data),
67
+ )
68
+
69
+ instance_data = instance.model_dump(mode="json")
70
+ await client.setex(
71
+ self._instance_key(service.name, instance.url),
72
+ self._instance_ttl,
73
+ json.dumps(instance_data),
74
+ )
75
+
76
+ async def unregister(self, name: str, instance_url: str) -> None:
77
+ """Remove a service instance from the registry."""
78
+ client = await self._get_client()
79
+ await client.delete(self._instance_key(name, instance_url))
80
+
81
+ instances = await self._get_instances(name)
82
+ if not instances:
83
+ await client.delete(self._service_key(name))
84
+
85
+ async def _get_instances(self, name: str) -> list[ServiceInstance]:
86
+ """Get all instances for a service."""
87
+ client = await self._get_client()
88
+ pattern = f"{self.INSTANCE_KEY_PREFIX}{name}:*"
89
+ instances = []
90
+
91
+ async for key in client.scan_iter(match=pattern):
92
+ data = await client.get(key)
93
+ if data:
94
+ instance_data = json.loads(data)
95
+ instance_data["last_heartbeat"] = datetime.fromisoformat(
96
+ instance_data["last_heartbeat"]
97
+ )
98
+ instances.append(ServiceInstance(**instance_data))
99
+
100
+ return instances
101
+
102
+ async def get_service(self, name: str) -> ServiceInfo | None:
103
+ """Get a service by name."""
104
+ client = await self._get_client()
105
+ data = await client.get(self._service_key(name))
106
+
107
+ if not data:
108
+ return None
109
+
110
+ service_data = json.loads(data)
111
+ instances = await self._get_instances(name)
112
+ service_data["instances"] = [inst.model_dump() for inst in instances]
113
+
114
+ return ServiceInfo(**service_data)
115
+
116
+ async def get_all_services(self) -> list[ServiceInfo]:
117
+ """Get all registered services."""
118
+ client = await self._get_client()
119
+ pattern = f"{self.SERVICE_KEY_PREFIX}*"
120
+ services = []
121
+
122
+ async for key in client.scan_iter(match=pattern):
123
+ name = key.decode() if isinstance(key, bytes) else key
124
+ name = name.replace(self.SERVICE_KEY_PREFIX, "")
125
+ service = await self.get_service(name)
126
+ if service:
127
+ services.append(service)
128
+
129
+ return services
130
+
131
+ async def heartbeat(self, name: str, instance_url: str) -> None:
132
+ """Update the heartbeat timestamp for a service instance."""
133
+ client = await self._get_client()
134
+ key = self._instance_key(name, instance_url)
135
+ data = await client.get(key)
136
+
137
+ if data:
138
+ instance_data = json.loads(data)
139
+ instance_data["last_heartbeat"] = datetime.now(UTC).isoformat()
140
+ instance_data["healthy"] = True
141
+ await client.setex(key, self._instance_ttl, json.dumps(instance_data))
142
+
143
+ async def cleanup_stale(self, max_age_seconds: int = 60) -> None:
144
+ """Remove stale instances.
145
+
146
+ Note: With Redis, TTL handles automatic expiration.
147
+ This method is provided for manual cleanup if needed.
148
+ """
149
+ client = await self._get_client()
150
+ cutoff = datetime.now(UTC) - timedelta(seconds=max_age_seconds)
151
+
152
+ for service in await self.get_all_services():
153
+ for instance in service.instances:
154
+ if instance.last_heartbeat < cutoff:
155
+ await client.delete(
156
+ self._instance_key(service.name, instance.url)
157
+ )
158
+
159
+ async def close(self) -> None:
160
+ """Close the Redis connection."""
161
+ if self._client:
162
+ await self._client.close()
163
+ self._client = None
@@ -0,0 +1,190 @@
1
+ """Async client for communicating with the service registry."""
2
+
3
+ import asyncio
4
+ import logging
5
+
6
+ import httpx
7
+
8
+ from tapper.exceptions import RegistryError
9
+ from tapper.models import ServiceInfo, ServiceInstance
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class RegistryClient:
15
+ """Async client for the Tapper service registry.
16
+
17
+ Handles registration, unregistration, heartbeats, and service discovery.
18
+ """
19
+
20
+ def __init__(
21
+ self,
22
+ registry_url: str,
23
+ timeout: float = 10.0,
24
+ ) -> None:
25
+ """Initialize the registry client.
26
+
27
+ Args:
28
+ registry_url: Base URL of the registry server.
29
+ timeout: Request timeout in seconds.
30
+ """
31
+ self._registry_url = registry_url.rstrip("/")
32
+ self._timeout = timeout
33
+ self._client: httpx.AsyncClient | None = None
34
+ self._heartbeat_task: asyncio.Task | None = None
35
+
36
+ async def _get_client(self) -> httpx.AsyncClient:
37
+ """Get or create the HTTP client."""
38
+ if self._client is None:
39
+ self._client = httpx.AsyncClient(timeout=self._timeout)
40
+ return self._client
41
+
42
+ async def register(
43
+ self,
44
+ service: ServiceInfo,
45
+ instance: ServiceInstance,
46
+ ) -> None:
47
+ """Register a service instance with the registry.
48
+
49
+ Args:
50
+ service: Service information including routes.
51
+ instance: Instance-specific information.
52
+
53
+ Raises:
54
+ RegistryError: If registration fails.
55
+ """
56
+ client = await self._get_client()
57
+ try:
58
+ response = await client.post(
59
+ f"{self._registry_url}/register",
60
+ json={
61
+ "service": service.model_dump(mode="json"),
62
+ "instance": instance.model_dump(mode="json"),
63
+ },
64
+ )
65
+ response.raise_for_status()
66
+ except httpx.HTTPError as e:
67
+ raise RegistryError(f"Failed to register service: {e}") from e
68
+
69
+ async def unregister(self, name: str, instance_url: str) -> None:
70
+ """Remove a service instance from the registry.
71
+
72
+ Args:
73
+ name: Service name.
74
+ instance_url: URL of the instance to unregister.
75
+
76
+ Raises:
77
+ RegistryError: If unregistration fails.
78
+ """
79
+ client = await self._get_client()
80
+ try:
81
+ response = await client.post(
82
+ f"{self._registry_url}/unregister",
83
+ json={"name": name, "instance_url": instance_url},
84
+ )
85
+ response.raise_for_status()
86
+ except httpx.HTTPError as e:
87
+ raise RegistryError(f"Failed to unregister service: {e}") from e
88
+
89
+ async def get_services(self) -> list[ServiceInfo]:
90
+ """Get all registered services from the registry.
91
+
92
+ Returns:
93
+ List of registered services.
94
+
95
+ Raises:
96
+ RegistryError: If the request fails.
97
+ """
98
+ client = await self._get_client()
99
+ try:
100
+ response = await client.get(f"{self._registry_url}/services")
101
+ response.raise_for_status()
102
+ return [ServiceInfo(**svc) for svc in response.json()]
103
+ except httpx.HTTPError as e:
104
+ raise RegistryError(f"Failed to get services: {e}") from e
105
+
106
+ async def get_service(self, name: str) -> ServiceInfo | None:
107
+ """Get a specific service by name.
108
+
109
+ Args:
110
+ name: Service name.
111
+
112
+ Returns:
113
+ Service information or None if not found.
114
+
115
+ Raises:
116
+ RegistryError: If the request fails (except 404).
117
+ """
118
+ client = await self._get_client()
119
+ try:
120
+ response = await client.get(f"{self._registry_url}/services/{name}")
121
+ if response.status_code == 404:
122
+ return None
123
+ response.raise_for_status()
124
+ return ServiceInfo(**response.json())
125
+ except httpx.HTTPError as e:
126
+ raise RegistryError(f"Failed to get service: {e}") from e
127
+
128
+ async def heartbeat(self, name: str, instance_url: str) -> None:
129
+ """Send a heartbeat for a service instance.
130
+
131
+ Args:
132
+ name: Service name.
133
+ instance_url: URL of the instance.
134
+
135
+ Raises:
136
+ RegistryError: If the heartbeat fails.
137
+ """
138
+ client = await self._get_client()
139
+ try:
140
+ response = await client.post(
141
+ f"{self._registry_url}/heartbeat/{name}",
142
+ json={"instance_url": instance_url},
143
+ )
144
+ response.raise_for_status()
145
+ except httpx.HTTPError as e:
146
+ logger.warning(f"Heartbeat failed for {name}: {e}")
147
+ raise RegistryError(f"Heartbeat failed: {e}") from e
148
+
149
+ def start_heartbeat(
150
+ self,
151
+ name: str,
152
+ instance_url: str,
153
+ interval: float = 30.0,
154
+ ) -> None:
155
+ """Start a background task to send periodic heartbeats.
156
+
157
+ Args:
158
+ name: Service name.
159
+ instance_url: URL of the instance.
160
+ interval: Seconds between heartbeats.
161
+ """
162
+ if self._heartbeat_task is not None:
163
+ self._heartbeat_task.cancel()
164
+
165
+ async def heartbeat_loop() -> None:
166
+ while True:
167
+ try:
168
+ await self.heartbeat(name, instance_url)
169
+ except RegistryError:
170
+ pass # Already logged in heartbeat()
171
+ await asyncio.sleep(interval)
172
+
173
+ self._heartbeat_task = asyncio.create_task(heartbeat_loop())
174
+
175
+ async def stop_heartbeat(self) -> None:
176
+ """Stop the background heartbeat task."""
177
+ if self._heartbeat_task is not None:
178
+ self._heartbeat_task.cancel()
179
+ try:
180
+ await self._heartbeat_task
181
+ except asyncio.CancelledError:
182
+ pass
183
+ self._heartbeat_task = None
184
+
185
+ async def close(self) -> None:
186
+ """Close the client and stop any background tasks."""
187
+ await self.stop_heartbeat()
188
+ if self._client is not None:
189
+ await self._client.aclose()
190
+ self._client = None
@@ -0,0 +1,106 @@
1
+ """FastAPI-based service registry server."""
2
+
3
+ import asyncio
4
+ from contextlib import asynccontextmanager
5
+ from typing import AsyncIterator
6
+
7
+ from fastapi import FastAPI, HTTPException, status
8
+
9
+ from tapper.models import (
10
+ HeartbeatRequest,
11
+ RegisterRequest,
12
+ ServiceInfo,
13
+ UnregisterRequest,
14
+ )
15
+ from tapper.registry.backends.base import RegistryBackend
16
+ from tapper.registry.backends.memory import InMemoryBackend
17
+
18
+
19
+ def create_registry_app(
20
+ backend: RegistryBackend | None = None,
21
+ cleanup_interval: int = 30,
22
+ stale_threshold: int = 60,
23
+ ) -> FastAPI:
24
+ """Create a FastAPI application for the service registry.
25
+
26
+ Args:
27
+ backend: Registry backend to use. Defaults to InMemoryBackend.
28
+ cleanup_interval: Seconds between stale instance cleanup runs.
29
+ stale_threshold: Seconds after which an instance is considered stale.
30
+ """
31
+ if backend is None:
32
+ backend = InMemoryBackend()
33
+
34
+ cleanup_task: asyncio.Task | None = None
35
+
36
+ async def cleanup_loop() -> None:
37
+ """Periodically clean up stale service instances."""
38
+ while True:
39
+ await asyncio.sleep(cleanup_interval)
40
+ await backend.cleanup_stale(stale_threshold)
41
+
42
+ @asynccontextmanager
43
+ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
44
+ nonlocal cleanup_task
45
+ cleanup_task = asyncio.create_task(cleanup_loop())
46
+ yield
47
+ if cleanup_task:
48
+ cleanup_task.cancel()
49
+ try:
50
+ await cleanup_task
51
+ except asyncio.CancelledError:
52
+ pass
53
+
54
+ app = FastAPI(
55
+ title="Tapper Service Registry",
56
+ description="Central registry for service discovery",
57
+ version="0.1.0",
58
+ lifespan=lifespan,
59
+ )
60
+
61
+ @app.post("/register", status_code=status.HTTP_201_CREATED)
62
+ async def register(request: RegisterRequest) -> dict:
63
+ """Register a service instance with the registry."""
64
+ await backend.register(request.service, request.instance)
65
+ return {"status": "registered", "service": request.service.name}
66
+
67
+ @app.post("/unregister", status_code=status.HTTP_200_OK)
68
+ async def unregister(request: UnregisterRequest) -> dict:
69
+ """Remove a service instance from the registry."""
70
+ await backend.unregister(request.name, request.instance_url)
71
+ return {"status": "unregistered", "service": request.name}
72
+
73
+ @app.get("/services", response_model=list[ServiceInfo])
74
+ async def list_services() -> list[ServiceInfo]:
75
+ """List all registered services."""
76
+ return await backend.get_all_services()
77
+
78
+ @app.get("/services/{name}", response_model=ServiceInfo)
79
+ async def get_service(name: str) -> ServiceInfo:
80
+ """Get a specific service by name."""
81
+ service = await backend.get_service(name)
82
+ if service is None:
83
+ raise HTTPException(
84
+ status_code=status.HTTP_404_NOT_FOUND,
85
+ detail=f"Service not found: {name}",
86
+ )
87
+ return service
88
+
89
+ @app.post("/heartbeat/{name}", status_code=status.HTTP_200_OK)
90
+ async def heartbeat(name: str, request: HeartbeatRequest) -> dict:
91
+ """Update heartbeat for a service instance."""
92
+ service = await backend.get_service(name)
93
+ if service is None:
94
+ raise HTTPException(
95
+ status_code=status.HTTP_404_NOT_FOUND,
96
+ detail=f"Service not found: {name}",
97
+ )
98
+ await backend.heartbeat(name, request.instance_url)
99
+ return {"status": "ok"}
100
+
101
+ @app.get("/health")
102
+ async def health() -> dict:
103
+ """Health check endpoint for the registry itself."""
104
+ return {"status": "healthy"}
105
+
106
+ return app