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.
tapper/__init__.py ADDED
@@ -0,0 +1,66 @@
1
+ """Tapper - A microservices framework for FastAPI.
2
+
3
+ Tapper provides service discovery and API gateway routing for FastAPI applications.
4
+
5
+ Quick Start:
6
+ # Register a service
7
+ from fastapi import FastAPI
8
+ from tapper import Service
9
+
10
+ @Service(name="user-service", version="1.0.0")
11
+ app = FastAPI()
12
+
13
+ # Create a gateway
14
+ from tapper import TapperGateway
15
+
16
+ gateway = TapperGateway(registry_url="http://localhost:8001")
17
+
18
+ # Run with uvicorn
19
+ # uvicorn gateway:gateway --port 8000
20
+
21
+ Example:
22
+ See the README for full usage examples.
23
+ """
24
+
25
+ from tapper.exceptions import (
26
+ LoadBalancerError,
27
+ NoHealthyInstanceError,
28
+ RegistryError,
29
+ ServiceNotFoundError,
30
+ TapperException,
31
+ )
32
+ from tapper.gateway import TapperGateway
33
+ from tapper.load_balancer import (
34
+ LeastConnectionsBalancer,
35
+ LoadBalancer,
36
+ RandomBalancer,
37
+ RoundRobinBalancer,
38
+ )
39
+ from tapper.models import Route, ServiceInfo, ServiceInstance
40
+ from tapper.registry import RegistryBackend
41
+ from tapper.service import Service
42
+
43
+ __version__ = "0.1.0"
44
+
45
+ __all__ = [
46
+ # Core
47
+ "Service",
48
+ "TapperGateway",
49
+ # Load Balancing
50
+ "LoadBalancer",
51
+ "RoundRobinBalancer",
52
+ "RandomBalancer",
53
+ "LeastConnectionsBalancer",
54
+ # Models
55
+ "ServiceInfo",
56
+ "ServiceInstance",
57
+ "Route",
58
+ # Registry
59
+ "RegistryBackend",
60
+ # Exceptions
61
+ "TapperException",
62
+ "RegistryError",
63
+ "ServiceNotFoundError",
64
+ "NoHealthyInstanceError",
65
+ "LoadBalancerError",
66
+ ]
tapper/cli.py ADDED
@@ -0,0 +1,109 @@
1
+ """Command-line interface for Tapper."""
2
+
3
+ import click
4
+ import uvicorn
5
+
6
+ from tapper.registry.backends import InMemoryBackend, RegistryBackend
7
+
8
+
9
+ def get_backend(backend_type: str, redis_url: str | None) -> RegistryBackend:
10
+ """Create a registry backend based on the specified type."""
11
+ if backend_type == "memory":
12
+ return InMemoryBackend()
13
+ elif backend_type == "redis":
14
+ try:
15
+ from tapper.registry.backends.redis import RedisBackend
16
+ except ImportError:
17
+ raise click.ClickException(
18
+ "Redis backend requires the 'redis' package. "
19
+ "Install with: pip install tapper[redis]"
20
+ )
21
+ url = redis_url or "redis://localhost:6379"
22
+ return RedisBackend(redis_url=url)
23
+ else:
24
+ raise click.ClickException(f"Unknown backend type: {backend_type}")
25
+
26
+
27
+ @click.group()
28
+ @click.version_option(version="0.1.0")
29
+ def main() -> None:
30
+ """Tapper - A microservices framework for FastAPI."""
31
+ pass
32
+
33
+
34
+ @main.command()
35
+ @click.option(
36
+ "--host",
37
+ default="0.0.0.0",
38
+ help="Host to bind the server to",
39
+ )
40
+ @click.option(
41
+ "--port",
42
+ default=8001,
43
+ type=int,
44
+ help="Port to bind the server to",
45
+ )
46
+ @click.option(
47
+ "--backend",
48
+ default="memory",
49
+ type=click.Choice(["memory", "redis"]),
50
+ help="Storage backend for the registry",
51
+ )
52
+ @click.option(
53
+ "--redis-url",
54
+ default=None,
55
+ help="Redis URL (only used with redis backend)",
56
+ )
57
+ @click.option(
58
+ "--reload",
59
+ is_flag=True,
60
+ default=False,
61
+ help="Enable auto-reload for development",
62
+ )
63
+ @click.option(
64
+ "--cleanup-interval",
65
+ default=30,
66
+ type=int,
67
+ help="Seconds between stale instance cleanup",
68
+ )
69
+ @click.option(
70
+ "--stale-threshold",
71
+ default=60,
72
+ type=int,
73
+ help="Seconds after which an instance is considered stale",
74
+ )
75
+ def registry(
76
+ host: str,
77
+ port: int,
78
+ backend: str,
79
+ redis_url: str | None,
80
+ reload: bool,
81
+ cleanup_interval: int,
82
+ stale_threshold: int,
83
+ ) -> None:
84
+ """Run the Tapper service registry server."""
85
+ click.echo(f"Starting Tapper registry on {host}:{port}")
86
+ click.echo(f"Backend: {backend}")
87
+
88
+ if reload:
89
+ uvicorn.run(
90
+ "tapper.registry.server:create_registry_app",
91
+ factory=True,
92
+ host=host,
93
+ port=port,
94
+ reload=reload,
95
+ )
96
+ else:
97
+ from tapper.registry.server import create_registry_app
98
+
99
+ backend_instance = get_backend(backend, redis_url)
100
+ app = create_registry_app(
101
+ backend=backend_instance,
102
+ cleanup_interval=cleanup_interval,
103
+ stale_threshold=stale_threshold,
104
+ )
105
+ uvicorn.run(app, host=host, port=port)
106
+
107
+
108
+ if __name__ == "__main__":
109
+ main()
tapper/exceptions.py ADDED
@@ -0,0 +1,35 @@
1
+ """Custom exceptions for the Tapper framework."""
2
+
3
+
4
+ class TapperException(Exception):
5
+ """Base exception for all Tapper-related errors."""
6
+
7
+ pass
8
+
9
+
10
+ class RegistryError(TapperException):
11
+ """Raised when there's an error communicating with the registry."""
12
+
13
+ pass
14
+
15
+
16
+ class ServiceNotFoundError(TapperException):
17
+ """Raised when a requested service is not found in the registry."""
18
+
19
+ def __init__(self, service_name: str):
20
+ self.service_name = service_name
21
+ super().__init__(f"Service not found: {service_name}")
22
+
23
+
24
+ class NoHealthyInstanceError(TapperException):
25
+ """Raised when no healthy instances are available for a service."""
26
+
27
+ def __init__(self, service_name: str):
28
+ self.service_name = service_name
29
+ super().__init__(f"No healthy instances available for service: {service_name}")
30
+
31
+
32
+ class LoadBalancerError(TapperException):
33
+ """Raised when there's an error in load balancer selection."""
34
+
35
+ pass
tapper/gateway.py ADDED
@@ -0,0 +1,294 @@
1
+ """API Gateway for routing requests to registered services."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import os
6
+ import re
7
+ from typing import Any, Callable
8
+
9
+ import httpx
10
+ from starlette.datastructures import Headers
11
+ from starlette.requests import Request
12
+ from starlette.responses import Response, StreamingResponse
13
+ from starlette.routing import compile_path
14
+
15
+ from tapper.exceptions import NoHealthyInstanceError, ServiceNotFoundError
16
+ from tapper.load_balancer import LoadBalancer, get_load_balancer
17
+ from tapper.models import ServiceInfo
18
+ from tapper.registry.client import RegistryClient
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ Scope = dict[str, Any]
23
+ Receive = Callable[[], Any]
24
+ Send = Callable[[dict[str, Any]], Any]
25
+
26
+
27
+ class RouteEntry:
28
+ """A compiled route entry for matching incoming requests."""
29
+
30
+ def __init__(self, path_pattern: str, methods: set[str], service: ServiceInfo):
31
+ self.path_pattern = path_pattern
32
+ self.methods = methods
33
+ self.service = service
34
+ self._regex, _, self._convertors = compile_path(path_pattern)
35
+
36
+ def match(self, path: str, method: str) -> dict[str, Any] | None:
37
+ """Check if the given path and method match this route.
38
+
39
+ Returns path parameters if matched, None otherwise.
40
+ """
41
+ if method not in self.methods:
42
+ return None
43
+
44
+ match = self._regex.match(path)
45
+ if match:
46
+ params = {}
47
+ for key, value in match.groupdict().items():
48
+ if key in self._convertors:
49
+ params[key] = self._convertors[key].convert(value)
50
+ else:
51
+ params[key] = value
52
+ return params
53
+ return None
54
+
55
+
56
+ class TapperGateway:
57
+ """ASGI application that acts as an API gateway.
58
+
59
+ Routes incoming requests to registered services based on their
60
+ declared routes, with load balancing across available instances.
61
+ """
62
+
63
+ def __init__(
64
+ self,
65
+ registry_url: str | None = None,
66
+ discovery_interval: int = 30,
67
+ timeout: float = 30.0,
68
+ load_balancer: LoadBalancer | str = "round-robin",
69
+ ) -> None:
70
+ """Initialize the gateway.
71
+
72
+ Args:
73
+ registry_url: URL of the registry server.
74
+ Defaults to TAPPER_REGISTRY_URL env var.
75
+ discovery_interval: Seconds between service discovery refreshes.
76
+ timeout: Request timeout in seconds.
77
+ load_balancer: Load balancer instance or strategy name.
78
+ """
79
+ self.registry_url = registry_url or os.environ.get(
80
+ "TAPPER_REGISTRY_URL", "http://localhost:8001"
81
+ )
82
+ self.discovery_interval = discovery_interval
83
+ self.timeout = timeout
84
+ self.load_balancer = get_load_balancer(load_balancer)
85
+
86
+ self._client: RegistryClient | None = None
87
+ self._http_client: httpx.AsyncClient | None = None
88
+ self._services: list[ServiceInfo] = []
89
+ self._routes: list[RouteEntry] = []
90
+ self._discovery_task: asyncio.Task | None = None
91
+ self._started = False
92
+
93
+ async def _get_client(self) -> RegistryClient:
94
+ """Get or create the registry client."""
95
+ if self._client is None:
96
+ self._client = RegistryClient(self.registry_url)
97
+ return self._client
98
+
99
+ async def _get_http_client(self) -> httpx.AsyncClient:
100
+ """Get or create the HTTP client for proxying."""
101
+ if self._http_client is None:
102
+ self._http_client = httpx.AsyncClient(timeout=self.timeout)
103
+ return self._http_client
104
+
105
+ async def _refresh_services(self) -> None:
106
+ """Refresh the service list from the registry."""
107
+ try:
108
+ client = await self._get_client()
109
+ self._services = await client.get_services()
110
+ self._build_route_table()
111
+ logger.debug(f"Refreshed services: {[s.name for s in self._services]}")
112
+ except Exception as e:
113
+ logger.error(f"Failed to refresh services: {e}")
114
+
115
+ def _build_route_table(self) -> None:
116
+ """Build the route table from registered services."""
117
+ routes = []
118
+ for service in self._services:
119
+ for route in service.routes:
120
+ routes.append(RouteEntry(
121
+ path_pattern=route.path,
122
+ methods=set(route.methods),
123
+ service=service,
124
+ ))
125
+ self._routes = routes
126
+
127
+ def _match_route(self, path: str, method: str) -> tuple[ServiceInfo, dict[str, Any]] | None:
128
+ """Find a matching route for the given path and method."""
129
+ for entry in self._routes:
130
+ params = entry.match(path, method)
131
+ if params is not None:
132
+ return entry.service, params
133
+ return None
134
+
135
+ async def _discovery_loop(self) -> None:
136
+ """Periodically refresh the service list."""
137
+ while True:
138
+ await self._refresh_services()
139
+ await asyncio.sleep(self.discovery_interval)
140
+
141
+ async def _startup(self) -> None:
142
+ """Start background tasks."""
143
+ if self._started:
144
+ return
145
+
146
+ await self._refresh_services()
147
+ self._discovery_task = asyncio.create_task(self._discovery_loop())
148
+ self._started = True
149
+
150
+ async def _shutdown(self) -> None:
151
+ """Clean up resources."""
152
+ if self._discovery_task:
153
+ self._discovery_task.cancel()
154
+ try:
155
+ await self._discovery_task
156
+ except asyncio.CancelledError:
157
+ pass
158
+
159
+ if self._client:
160
+ await self._client.close()
161
+
162
+ if self._http_client:
163
+ await self._http_client.aclose()
164
+
165
+ async def _proxy_request(
166
+ self,
167
+ request: Request,
168
+ service: ServiceInfo,
169
+ ) -> Response:
170
+ """Proxy a request to a service instance."""
171
+ if not service.instances:
172
+ raise NoHealthyInstanceError(service.name)
173
+
174
+ instance = self.load_balancer.select_instance(
175
+ service.instances,
176
+ service.name,
177
+ )
178
+
179
+ target_url = f"{instance.url.rstrip('/')}{request.url.path}"
180
+ if request.url.query:
181
+ target_url = f"{target_url}?{request.url.query}"
182
+
183
+ headers = dict(request.headers)
184
+ headers.pop("host", None)
185
+
186
+ body = await request.body()
187
+
188
+ http_client = await self._get_http_client()
189
+
190
+ try:
191
+ response = await http_client.request(
192
+ method=request.method,
193
+ url=target_url,
194
+ headers=headers,
195
+ content=body,
196
+ )
197
+
198
+ response_headers = dict(response.headers)
199
+ response_headers.pop("content-encoding", None)
200
+ response_headers.pop("content-length", None)
201
+ response_headers.pop("transfer-encoding", None)
202
+
203
+ return Response(
204
+ content=response.content,
205
+ status_code=response.status_code,
206
+ headers=response_headers,
207
+ )
208
+
209
+ except httpx.TimeoutException:
210
+ logger.error(f"Timeout proxying to {target_url}")
211
+ return Response(
212
+ content=b'{"error": "Gateway timeout"}',
213
+ status_code=504,
214
+ media_type="application/json",
215
+ )
216
+ except httpx.RequestError as e:
217
+ logger.error(f"Error proxying to {target_url}: {e}")
218
+ return Response(
219
+ content=b'{"error": "Bad gateway"}',
220
+ status_code=502,
221
+ media_type="application/json",
222
+ )
223
+
224
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
225
+ """ASGI application entry point."""
226
+ if scope["type"] == "lifespan":
227
+ await self._handle_lifespan(scope, receive, send)
228
+ return
229
+
230
+ if scope["type"] != "http":
231
+ return
232
+
233
+ await self._startup()
234
+
235
+ request = Request(scope, receive, send)
236
+
237
+ if request.url.path == "/health":
238
+ response = Response(
239
+ content=b'{"status": "healthy"}',
240
+ status_code=200,
241
+ media_type="application/json",
242
+ )
243
+ await response(scope, receive, send)
244
+ return
245
+
246
+ match = self._match_route(request.url.path, request.method)
247
+
248
+ if match is None:
249
+ response = Response(
250
+ content=b'{"error": "Not found"}',
251
+ status_code=404,
252
+ media_type="application/json",
253
+ )
254
+ await response(scope, receive, send)
255
+ return
256
+
257
+ service, _ = match
258
+
259
+ try:
260
+ response = await self._proxy_request(request, service)
261
+ await response(scope, receive, send)
262
+ except NoHealthyInstanceError:
263
+ response = Response(
264
+ content=b'{"error": "Service unavailable"}',
265
+ status_code=503,
266
+ media_type="application/json",
267
+ )
268
+ await response(scope, receive, send)
269
+
270
+ async def _handle_lifespan(
271
+ self,
272
+ scope: Scope,
273
+ receive: Receive,
274
+ send: Send,
275
+ ) -> None:
276
+ """Handle ASGI lifespan events."""
277
+ while True:
278
+ message = await receive()
279
+
280
+ if message["type"] == "lifespan.startup":
281
+ try:
282
+ await self._startup()
283
+ await send({"type": "lifespan.startup.complete"})
284
+ except Exception as e:
285
+ await send({
286
+ "type": "lifespan.startup.failed",
287
+ "message": str(e),
288
+ })
289
+ return
290
+
291
+ elif message["type"] == "lifespan.shutdown":
292
+ await self._shutdown()
293
+ await send({"type": "lifespan.shutdown.complete"})
294
+ return
@@ -0,0 +1,148 @@
1
+ """Load balancing strategies for service instances."""
2
+
3
+ import random
4
+ from abc import ABC, abstractmethod
5
+ from collections import defaultdict
6
+
7
+ from tapper.exceptions import NoHealthyInstanceError
8
+ from tapper.models import ServiceInstance
9
+
10
+
11
+ class LoadBalancer(ABC):
12
+ """Abstract base class for load balancing strategies."""
13
+
14
+ @abstractmethod
15
+ def select_instance(
16
+ self,
17
+ instances: list[ServiceInstance],
18
+ service_name: str = "",
19
+ ) -> ServiceInstance:
20
+ """Select an instance from the list of available instances.
21
+
22
+ Args:
23
+ instances: List of available service instances.
24
+ service_name: Name of the service (for stateful balancers).
25
+
26
+ Returns:
27
+ The selected instance.
28
+
29
+ Raises:
30
+ NoHealthyInstanceError: If no healthy instances are available.
31
+ """
32
+ pass
33
+
34
+ def _filter_healthy(self, instances: list[ServiceInstance]) -> list[ServiceInstance]:
35
+ """Filter to only healthy instances."""
36
+ return [inst for inst in instances if inst.healthy]
37
+
38
+
39
+ class RoundRobinBalancer(LoadBalancer):
40
+ """Round-robin load balancing strategy.
41
+
42
+ Distributes requests evenly across all healthy instances.
43
+ """
44
+
45
+ def __init__(self) -> None:
46
+ self._counters: dict[str, int] = defaultdict(int)
47
+
48
+ def select_instance(
49
+ self,
50
+ instances: list[ServiceInstance],
51
+ service_name: str = "",
52
+ ) -> ServiceInstance:
53
+ healthy = self._filter_healthy(instances)
54
+ if not healthy:
55
+ raise NoHealthyInstanceError(service_name)
56
+
57
+ index = self._counters[service_name] % len(healthy)
58
+ self._counters[service_name] += 1
59
+ return healthy[index]
60
+
61
+
62
+ class RandomBalancer(LoadBalancer):
63
+ """Random load balancing strategy.
64
+
65
+ Randomly selects from healthy instances.
66
+ """
67
+
68
+ def select_instance(
69
+ self,
70
+ instances: list[ServiceInstance],
71
+ service_name: str = "",
72
+ ) -> ServiceInstance:
73
+ healthy = self._filter_healthy(instances)
74
+ if not healthy:
75
+ raise NoHealthyInstanceError(service_name)
76
+
77
+ return random.choice(healthy)
78
+
79
+
80
+ class LeastConnectionsBalancer(LoadBalancer):
81
+ """Least connections load balancing strategy.
82
+
83
+ Selects the instance with the fewest active connections.
84
+ Note: Requires external connection tracking.
85
+ """
86
+
87
+ def __init__(self) -> None:
88
+ self._connections: dict[str, int] = defaultdict(int)
89
+
90
+ def select_instance(
91
+ self,
92
+ instances: list[ServiceInstance],
93
+ service_name: str = "",
94
+ ) -> ServiceInstance:
95
+ healthy = self._filter_healthy(instances)
96
+ if not healthy:
97
+ raise NoHealthyInstanceError(service_name)
98
+
99
+ min_connections = float("inf")
100
+ selected = healthy[0]
101
+
102
+ for inst in healthy:
103
+ conn_count = self._connections[inst.url]
104
+ if conn_count < min_connections:
105
+ min_connections = conn_count
106
+ selected = inst
107
+
108
+ return selected
109
+
110
+ def increment(self, url: str) -> None:
111
+ """Increment the connection count for an instance."""
112
+ self._connections[url] += 1
113
+
114
+ def decrement(self, url: str) -> None:
115
+ """Decrement the connection count for an instance."""
116
+ if self._connections[url] > 0:
117
+ self._connections[url] -= 1
118
+
119
+
120
+ def get_load_balancer(strategy: str | LoadBalancer) -> LoadBalancer:
121
+ """Get a load balancer instance by name or return the provided instance.
122
+
123
+ Args:
124
+ strategy: Either a LoadBalancer instance or a string name
125
+ ("round-robin", "random", "least-connections").
126
+
127
+ Returns:
128
+ A LoadBalancer instance.
129
+
130
+ Raises:
131
+ ValueError: If the strategy name is unknown.
132
+ """
133
+ if isinstance(strategy, LoadBalancer):
134
+ return strategy
135
+
136
+ strategies = {
137
+ "round-robin": RoundRobinBalancer,
138
+ "random": RandomBalancer,
139
+ "least-connections": LeastConnectionsBalancer,
140
+ }
141
+
142
+ if strategy not in strategies:
143
+ raise ValueError(
144
+ f"Unknown load balancer strategy: {strategy}. "
145
+ f"Available: {list(strategies.keys())}"
146
+ )
147
+
148
+ return strategies[strategy]()
tapper/models.py ADDED
@@ -0,0 +1,53 @@
1
+ """Pydantic models for the Tapper framework."""
2
+
3
+ from datetime import UTC, datetime
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class Route(BaseModel):
9
+ """Represents an API route exposed by a service."""
10
+
11
+ path: str = Field(..., description="Route path pattern, e.g., '/users/{user_id}'")
12
+ methods: list[str] = Field(..., description="HTTP methods, e.g., ['GET', 'POST']")
13
+
14
+
15
+ class ServiceInstance(BaseModel):
16
+ """Represents a running instance of a service."""
17
+
18
+ url: str = Field(..., description="Base URL of the instance, e.g., 'http://localhost:8001'")
19
+ health_endpoint: str = Field(default="/health", description="Health check endpoint path")
20
+ last_heartbeat: datetime = Field(default_factory=lambda: datetime.now(UTC))
21
+ healthy: bool = Field(default=True)
22
+
23
+
24
+ class ServiceInfo(BaseModel):
25
+ """Complete information about a registered service."""
26
+
27
+ name: str = Field(..., description="Unique service name")
28
+ version: str = Field(..., description="Service version")
29
+ description: str | None = Field(default=None)
30
+ prefix: str | None = Field(default=None, description="URL prefix for routing")
31
+ routes: list[Route] = Field(default_factory=list)
32
+ instances: list[ServiceInstance] = Field(default_factory=list)
33
+ tags: list[str] = Field(default_factory=list)
34
+
35
+
36
+ class RegisterRequest(BaseModel):
37
+ """Request body for service registration."""
38
+
39
+ service: ServiceInfo
40
+ instance: ServiceInstance
41
+
42
+
43
+ class HeartbeatRequest(BaseModel):
44
+ """Request body for heartbeat updates."""
45
+
46
+ instance_url: str
47
+
48
+
49
+ class UnregisterRequest(BaseModel):
50
+ """Request body for service unregistration."""
51
+
52
+ name: str
53
+ instance_url: str