api-service-handler 0.1.6__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,77 @@
1
+ """Enterprise API key management: rotation, rate-limiting, usage tracking, multi-backend storage.
2
+
3
+ A reusable Python library for managing API keys across all your business services.
4
+ Built with uv, supports multiple storage backends, and provides round-robin rotation,
5
+ rate limiting, usage tracking, concurrent usage control, and rich metadata.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from .client import APIServiceHandler, SyncAPIServiceHandler
11
+ from .config import ASHConfig, get_config_from_env
12
+ from .enums import Environment, KeyStatus, Provider, RotationStrategy, StorageBackend
13
+ from .exceptions import (
14
+ APIServiceHandlerError,
15
+ DuplicateKeyError,
16
+ EncryptionError,
17
+ InvalidProviderError,
18
+ KeyExpiredError,
19
+ KeyNotFoundError,
20
+ MaxConcurrentExceededError,
21
+ NoAvailableKeyError,
22
+ RateLimitExceededError,
23
+ StorageConnectionError,
24
+ StorageNotInitializedError,
25
+ )
26
+ from .models import (
27
+ APIKey,
28
+ BulkOperationResult,
29
+ KeyCreateRequest,
30
+ KeyFilter,
31
+ KeyUpdateRequest,
32
+ UsageStats,
33
+ )
34
+
35
+ import importlib.metadata
36
+
37
+ try:
38
+ __version__ = importlib.metadata.version("api-service-handler")
39
+ except importlib.metadata.PackageNotFoundError:
40
+ __version__ = "unknown"
41
+ __all__ = [
42
+ # Main clients
43
+ "APIServiceHandler",
44
+ "SyncAPIServiceHandler",
45
+
46
+ # Configuration
47
+ "ASHConfig",
48
+ "get_config_from_env",
49
+
50
+ # Enums
51
+ "Environment",
52
+ "KeyStatus",
53
+ "Provider",
54
+ "RotationStrategy",
55
+ "StorageBackend",
56
+
57
+ # Models
58
+ "APIKey",
59
+ "BulkOperationResult",
60
+ "KeyCreateRequest",
61
+ "KeyFilter",
62
+ "KeyUpdateRequest",
63
+ "UsageStats",
64
+
65
+ # Exceptions
66
+ "APIServiceHandlerError",
67
+ "DuplicateKeyError",
68
+ "EncryptionError",
69
+ "InvalidProviderError",
70
+ "KeyExpiredError",
71
+ "KeyNotFoundError",
72
+ "MaxConcurrentExceededError",
73
+ "NoAvailableKeyError",
74
+ "RateLimitExceededError",
75
+ "StorageConnectionError",
76
+ "StorageNotInitializedError",
77
+ ]
@@ -0,0 +1,341 @@
1
+ """Command Line Interface for the API Service Handler."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ from datetime import datetime
8
+ from functools import wraps
9
+ from typing import Any, Optional
10
+
11
+ import click
12
+ from rich.console import Console
13
+ from rich.table import Table
14
+
15
+ from .client import APIServiceHandler
16
+ from .config import get_config_from_env
17
+ from .enums import Provider, KeyStatus, Environment
18
+
19
+ console = Console()
20
+
21
+ def coro(f):
22
+ @wraps(f)
23
+ def wrapper(*args, **kwargs):
24
+ return asyncio.run(f(*args, **kwargs))
25
+ return wrapper
26
+
27
+ @click.group()
28
+ @click.pass_context
29
+ def cli(ctx: click.Context):
30
+ """API Service Handler (ash) CLI.
31
+
32
+ Enterprise API key management: rotation, rate-limiting, usage tracking.
33
+ """
34
+ # Load configuration from environment
35
+ config = get_config_from_env()
36
+ ctx.ensure_object(dict)
37
+ ctx.obj['config'] = config
38
+
39
+ # Do not initialize handler yet, we only need it for subcommands
40
+ # and they might have their own error handling.
41
+
42
+ async def get_handler(ctx: click.Context) -> APIServiceHandler:
43
+ config = ctx.obj['config']
44
+ handler = APIServiceHandler(config=config)
45
+ try:
46
+ await handler.initialize()
47
+ except Exception as e:
48
+ console.print(f"[bold red]Failed to initialize storage:[/bold red] {e}")
49
+ ctx.exit(1)
50
+ return handler
51
+
52
+ @cli.group()
53
+ def keys():
54
+ """Manage API keys."""
55
+ pass
56
+
57
+ @keys.command(name="add")
58
+ @click.option('--provider', required=True, help='API provider name (e.g. openai)')
59
+ @click.option('--key', required=True, help='The API key value')
60
+ @click.option('--alias', help='Optional human-friendly name')
61
+ @click.option('--daily-limit', type=int, help='Max requests per day')
62
+ @click.option('--monthly-limit', type=int, help='Max requests per month')
63
+ @click.option('--max-concurrent', type=int, help='Max simultaneous uses')
64
+ @click.option('--environment', default='production', help='Deployment environment')
65
+ @click.pass_context
66
+ @coro
67
+ async def keys_add(ctx: click.Context, provider: str, key: str, alias: Optional[str],
68
+ daily_limit: Optional[int], monthly_limit: Optional[int],
69
+ max_concurrent: Optional[int], environment: str):
70
+ """Add a new API key."""
71
+ handler = await get_handler(ctx)
72
+ try:
73
+ api_key = await handler.add_key(
74
+ provider=provider,
75
+ key_value=key,
76
+ alias=alias,
77
+ daily_limit=daily_limit,
78
+ monthly_limit=monthly_limit,
79
+ max_concurrent=max_concurrent,
80
+ environment=environment
81
+ )
82
+ console.print(f"[bold green]Successfully added key![/bold green]")
83
+ console.print(f"ID: {api_key.id}")
84
+ provider_str = api_key.provider if isinstance(api_key.provider, str) else api_key.provider.value
85
+ console.print(f"Provider: {provider_str}")
86
+ if alias:
87
+ console.print(f"Alias: {api_key.alias}")
88
+ except Exception as e:
89
+ console.print(f"[bold red]Error:[/bold red] {e}")
90
+ finally:
91
+ await handler.close()
92
+
93
+ @keys.command(name="list")
94
+ @click.option('--provider', help='Filter by provider')
95
+ @click.option('--status', help='Filter by status (e.g. active, revoked)')
96
+ @click.option('--show-keys', is_flag=True, help='Display full key values instead of masking')
97
+ @click.option('--json', 'as_json', is_flag=True, help='Output as JSON')
98
+ @click.pass_context
99
+ @coro
100
+ async def keys_list(ctx: click.Context, provider: Optional[str], status: Optional[str],
101
+ show_keys: bool, as_json: bool):
102
+ """List API keys."""
103
+ handler = await get_handler(ctx)
104
+ try:
105
+ api_keys = await handler.get_all_keys(
106
+ provider=provider,
107
+ status=status,
108
+ decrypt=show_keys
109
+ )
110
+
111
+ if as_json:
112
+ # Pydantic v2 compatible
113
+ data = [k.model_dump(mode="json") for k in api_keys]
114
+ console.print(json.dumps(data, indent=2))
115
+ return
116
+
117
+ table = Table(title="API Keys")
118
+ table.add_column("ID", style="cyan", no_wrap=True)
119
+ table.add_column("Provider", style="magenta")
120
+ table.add_column("Alias")
121
+ table.add_column("Key Value")
122
+ table.add_column("Status")
123
+ table.add_column("Usage (D/M/T)")
124
+
125
+ for k in api_keys:
126
+ key_val = k.key_value if show_keys else f"{k.key_value[:8]}***" if len(k.key_value) > 8 else "***"
127
+
128
+ provider_str = k.provider if isinstance(k.provider, str) else k.provider.value
129
+ status_str = k.status if isinstance(k.status, str) else k.status.value
130
+ status_color = "green" if status_str == "active" else "red" if status_str == "revoked" else "yellow"
131
+ status_text = f"[{status_color}]{status_str}[/{status_color}]"
132
+
133
+ usage_text = f"{k.daily_usage_count}/{k.monthly_usage_count}/{k.total_usage_count}"
134
+
135
+ table.add_row(
136
+ k.id[:8] + "...",
137
+ provider_str,
138
+ k.alias or "-",
139
+ key_val,
140
+ status_text,
141
+ usage_text
142
+ )
143
+
144
+ console.print(table)
145
+ console.print(f"Total keys: {len(api_keys)}")
146
+
147
+ except Exception as e:
148
+ console.print(f"[bold red]Error:[/bold red] {e}")
149
+ finally:
150
+ await handler.close()
151
+
152
+ @keys.command(name="get")
153
+ @click.argument('key_id')
154
+ @click.option('--show-key', is_flag=True, help='Display full key value instead of masking')
155
+ @click.pass_context
156
+ @coro
157
+ async def keys_get(ctx: click.Context, key_id: str, show_key: bool):
158
+ """Get details for a specific API key."""
159
+ handler = await get_handler(ctx)
160
+ try:
161
+ key = await handler.get_key(key_id, decrypt=show_key)
162
+
163
+ console.print(f"[bold]Key Details for [cyan]{key.id}[/cyan][/bold]")
164
+ provider_str = key.provider if isinstance(key.provider, str) else key.provider.value
165
+ console.print(f"Provider: {provider_str}")
166
+ console.print(f"Alias: {key.alias or '-'}")
167
+ key_val = key.key_value if show_key else f"{key.key_value[:8]}***" if len(key.key_value) > 8 else "***"
168
+ console.print(f"Key Value: {key_val}")
169
+
170
+ status_str = key.status if isinstance(key.status, str) else key.status.value
171
+ status_color = "green" if status_str == "active" else "red" if status_str == "revoked" else "yellow"
172
+ console.print(f"Status: [{status_color}]{status_str}[/{status_color}]")
173
+
174
+ env_str = key.environment if isinstance(key.environment, str) else key.environment.value
175
+ console.print(f"Environment: {env_str}")
176
+
177
+ console.print("\n[bold]Rate Limits & Usage[/bold]")
178
+ console.print(f"Daily Limit: {key.daily_limit or 'Unlimited'}")
179
+ console.print(f"Daily Usage: {key.daily_usage_count}")
180
+ console.print(f"Monthly Limit: {key.monthly_limit or 'Unlimited'}")
181
+ console.print(f"Monthly Usage: {key.monthly_usage_count}")
182
+ console.print(f"Total Usage: {key.total_usage_count}")
183
+ console.print(f"Concurrent Max: {key.max_concurrent or 'Unlimited'}")
184
+ console.print(f"Concurrent Curr: {key.concurrent_usage}")
185
+
186
+ except Exception as e:
187
+ console.print(f"[bold red]Error:[/bold red] {e}")
188
+ finally:
189
+ await handler.close()
190
+
191
+ @keys.command(name="update")
192
+ @click.argument('key_id')
193
+ @click.option('--alias', help='New human-friendly name')
194
+ @click.option('--status', help='New status (e.g. active, inactive)')
195
+ @click.option('--daily-limit', type=int, help='New max requests per day')
196
+ @click.option('--monthly-limit', type=int, help='New max requests per month')
197
+ @click.pass_context
198
+ @coro
199
+ async def keys_update(ctx: click.Context, key_id: str, alias: Optional[str],
200
+ status: Optional[str], daily_limit: Optional[int],
201
+ monthly_limit: Optional[int]):
202
+ """Update an existing API key."""
203
+ handler = await get_handler(ctx)
204
+ try:
205
+ updated = await handler.update_key(
206
+ key_id,
207
+ alias=alias,
208
+ status=status,
209
+ daily_limit=daily_limit,
210
+ monthly_limit=monthly_limit
211
+ )
212
+ console.print(f"[bold green]Successfully updated key {updated.id}[/bold green]")
213
+ except Exception as e:
214
+ console.print(f"[bold red]Error:[/bold red] {e}")
215
+ finally:
216
+ await handler.close()
217
+
218
+ @keys.command(name="delete")
219
+ @click.argument('key_id')
220
+ @click.option('--hard', is_flag=True, help='Hard delete from database instead of revoking')
221
+ @click.pass_context
222
+ @coro
223
+ async def keys_delete(ctx: click.Context, key_id: str, hard: bool):
224
+ """Delete or revoke an API key."""
225
+ handler = await get_handler(ctx)
226
+ try:
227
+ if not click.confirm(f"Are you sure you want to {'hard ' if hard else 'soft '}delete key {key_id}?"):
228
+ ctx.exit(0)
229
+
230
+ await handler.delete_key(key_id, hard=hard)
231
+ console.print(f"[bold green]Successfully deleted key {key_id}[/bold green]")
232
+ except Exception as e:
233
+ console.print(f"[bold red]Error:[/bold red] {e}")
234
+ finally:
235
+ await handler.close()
236
+
237
+ @cli.group()
238
+ def usage():
239
+ """View and reset usage statistics."""
240
+ pass
241
+
242
+ @usage.command(name="stats")
243
+ @click.argument('key_id')
244
+ @click.pass_context
245
+ @coro
246
+ async def usage_stats(ctx: click.Context, key_id: str):
247
+ """Get usage statistics for a specific key."""
248
+ handler = await get_handler(ctx)
249
+ try:
250
+ stats = await handler.get_usage_stats(key_id)
251
+
252
+ table = Table(title=f"Usage Stats for {key_id}")
253
+ table.add_column("Metric", style="cyan")
254
+ table.add_column("Value")
255
+
256
+ provider_str = stats.provider if isinstance(stats.provider, str) else stats.provider.value
257
+ table.add_row("Provider", provider_str)
258
+ if stats.alias:
259
+ table.add_row("Alias", stats.alias)
260
+
261
+ table.add_row("Daily Usage", str(stats.daily_usage_count))
262
+ table.add_row("Daily Remaining", str(stats.daily_remaining) if stats.daily_remaining is not None else "Unlimited")
263
+
264
+ table.add_row("Monthly Usage", str(stats.monthly_usage_count))
265
+ table.add_row("Monthly Remaining", str(stats.monthly_remaining) if stats.monthly_remaining is not None else "Unlimited")
266
+
267
+ table.add_row("Total Usage", str(stats.total_usage_count))
268
+ table.add_row("Concurrent", str(stats.concurrent_usage))
269
+
270
+ console.print(table)
271
+ except Exception as e:
272
+ console.print(f"[bold red]Error:[/bold red] {e}")
273
+ finally:
274
+ await handler.close()
275
+
276
+ @usage.command(name="reset-daily")
277
+ @click.pass_context
278
+ @coro
279
+ async def usage_reset_daily(ctx: click.Context):
280
+ """Manually trigger daily reset for all keys."""
281
+ handler = await get_handler(ctx)
282
+ try:
283
+ count = await handler.reset_daily_counts()
284
+ console.print(f"[bold green]Reset daily counters for {count} keys.[/bold green]")
285
+ except Exception as e:
286
+ console.print(f"[bold red]Error:[/bold red] {e}")
287
+ finally:
288
+ await handler.close()
289
+
290
+ @usage.command(name="reset-monthly")
291
+ @click.pass_context
292
+ @coro
293
+ async def usage_reset_monthly(ctx: click.Context):
294
+ """Manually trigger monthly reset for all keys."""
295
+ handler = await get_handler(ctx)
296
+ try:
297
+ count = await handler.reset_monthly_counts()
298
+ console.print(f"[bold green]Reset monthly counters for {count} keys.[/bold green]")
299
+ except Exception as e:
300
+ console.print(f"[bold red]Error:[/bold red] {e}")
301
+ finally:
302
+ await handler.close()
303
+
304
+ @cli.command()
305
+ @click.pass_context
306
+ @coro
307
+ async def health(ctx: click.Context):
308
+ """Check the health of the storage backend."""
309
+ handler = await get_handler(ctx)
310
+ try:
311
+ status = await handler.health_check()
312
+ if status.get("storage_healthy"):
313
+ console.print("[bold green]Storage backend is healthy![/bold green]")
314
+ else:
315
+ console.print("[bold red]Storage backend is NOT healthy![/bold red]")
316
+
317
+ for k, v in status.items():
318
+ console.print(f"{k}: {v}")
319
+ except Exception as e:
320
+ console.print(f"[bold red]Error:[/bold red] {e}")
321
+ finally:
322
+ await handler.close()
323
+
324
+ @cli.command()
325
+ @click.pass_context
326
+ @coro
327
+ async def info(ctx: click.Context):
328
+ """View API Service Handler information and stats."""
329
+ handler = await get_handler(ctx)
330
+ try:
331
+ inf = await handler.info()
332
+ console.print("[bold]API Service Handler Info[/bold]")
333
+ for k, v in inf.items():
334
+ console.print(f"{k}: {v}")
335
+ except Exception as e:
336
+ console.print(f"[bold red]Error:[/bold red] {e}")
337
+ finally:
338
+ await handler.close()
339
+
340
+ if __name__ == '__main__':
341
+ cli()