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,177 @@
1
+ """Configuration module for the API Service Handler.
2
+
3
+ Provides :class:`ASHConfig` — a dataclass holding every tuneable knob — and
4
+ the helper :func:`get_config_from_env` that builds an ``ASHConfig`` purely
5
+ from environment variables prefixed with ``ASH_``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from dataclasses import dataclass, field
12
+ from typing import Optional
13
+
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # Supported literal values (kept as module-level constants for validation)
17
+ # ---------------------------------------------------------------------------
18
+
19
+ VALID_STORAGE_BACKENDS = frozenset({"memory", "sqlite", "mongodb", "postgresql"})
20
+ VALID_ROTATION_STRATEGIES = frozenset({"round_robin", "least_used", "random", "weighted"})
21
+
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Core configuration dataclass
25
+ # ---------------------------------------------------------------------------
26
+
27
+
28
+ @dataclass
29
+ class ASHConfig:
30
+ """Configuration for the API Service Handler.
31
+
32
+ Attributes
33
+ ----------
34
+ storage_backend:
35
+ Which persistence layer to use. One of ``memory``, ``sqlite``,
36
+ ``mongodb``, or ``postgresql``. Falls back to the
37
+ ``ASH_STORAGE_BACKEND`` env var when set to ``"memory"`` (the default).
38
+ connection_string:
39
+ Database connection URI. Falls back to ``ASH_CONNECTION_STRING``.
40
+ shared_secret:
41
+ Passphrase used to derive an AES-256-GCM key for encrypting stored API
42
+ keys. Falls back to ``ASH_SHARED_SECRET``.
43
+ encrypt_keys:
44
+ Whether API key values should be encrypted at rest.
45
+ rotation_strategy:
46
+ Algorithm used when picking the next key. One of ``round_robin``,
47
+ ``least_used``, ``random``, or ``weighted``.
48
+ auto_reset_counters:
49
+ Automatically reset daily / monthly usage counters on access when the
50
+ current period has elapsed.
51
+ soft_delete:
52
+ When ``True``, deleting a key sets its status to ``REVOKED`` rather
53
+ than removing the record.
54
+ default_daily_limit:
55
+ Default daily request cap applied to newly created keys.
56
+ default_monthly_limit:
57
+ Default monthly request cap applied to newly created keys.
58
+ default_max_concurrent:
59
+ Default maximum number of concurrent in-flight requests per key.
60
+ metadata_indexes:
61
+ List of metadata keys to create compound indexes for (e.g. ['client_id']).
62
+ """
63
+
64
+ # Storage
65
+ storage_backend: str = "memory"
66
+ connection_string: str = ""
67
+
68
+ # Encryption
69
+ shared_secret: str = ""
70
+ encrypt_keys: bool = True
71
+
72
+ # Rotation
73
+ rotation_strategy: str = "round_robin"
74
+
75
+ # Behaviour
76
+ auto_reset_counters: bool = True
77
+ soft_delete: bool = True
78
+ default_daily_limit: Optional[int] = None
79
+ default_monthly_limit: Optional[int] = None
80
+ default_max_concurrent: Optional[int] = None
81
+ metadata_indexes: list[str] = field(default_factory=list)
82
+
83
+ # ------------------------------------------------------------------
84
+ # Post-init: resolve env-var fallbacks & validate
85
+ # ------------------------------------------------------------------
86
+
87
+ def __post_init__(self) -> None:
88
+ # Env-var fallbacks
89
+ if not self.shared_secret:
90
+ self.shared_secret = os.environ.get("ASH_SHARED_SECRET", "")
91
+ if not self.connection_string:
92
+ self.connection_string = os.environ.get("ASH_CONNECTION_STRING", "")
93
+ if self.storage_backend == "memory":
94
+ self.storage_backend = os.environ.get("ASH_STORAGE_BACKEND", "memory")
95
+
96
+ # Normalise & validate
97
+ self.storage_backend = self.storage_backend.lower().strip()
98
+ self.rotation_strategy = self.rotation_strategy.lower().strip()
99
+
100
+ if self.storage_backend not in VALID_STORAGE_BACKENDS:
101
+ raise ValueError(
102
+ f"Invalid storage_backend {self.storage_backend!r}. "
103
+ f"Choose from {sorted(VALID_STORAGE_BACKENDS)}."
104
+ )
105
+ if self.rotation_strategy not in VALID_ROTATION_STRATEGIES:
106
+ raise ValueError(
107
+ f"Invalid rotation_strategy {self.rotation_strategy!r}. "
108
+ f"Choose from {sorted(VALID_ROTATION_STRATEGIES)}."
109
+ )
110
+
111
+ # Limit fields must be non-negative when set
112
+ for attr in ("default_daily_limit", "default_monthly_limit", "default_max_concurrent"):
113
+ value = getattr(self, attr)
114
+ if value is not None and value < 0:
115
+ raise ValueError(f"{attr} must be a non-negative integer, got {value}.")
116
+
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # Factory – build config entirely from environment variables
120
+ # ---------------------------------------------------------------------------
121
+
122
+
123
+ def _env_bool(name: str, default: bool) -> bool:
124
+ """Read an env var as a boolean (``true/1/yes`` → True)."""
125
+ raw = os.environ.get(name)
126
+ if raw is None:
127
+ return default
128
+ return raw.strip().lower() in {"true", "1", "yes"}
129
+
130
+
131
+ def _env_optional_int(name: str) -> Optional[int]:
132
+ """Read an env var as an optional integer."""
133
+ raw = os.environ.get(name)
134
+ if raw is None or raw.strip() == "":
135
+ return None
136
+ try:
137
+ return int(raw)
138
+ except ValueError:
139
+ raise ValueError(
140
+ f"Environment variable {name} must be an integer, got {raw!r}."
141
+ ) from None
142
+
143
+
144
+ def get_config_from_env() -> ASHConfig:
145
+ """Build an :class:`ASHConfig` entirely from ``ASH_*`` environment variables.
146
+
147
+ Recognised variables
148
+ --------------------
149
+ * ``ASH_STORAGE_BACKEND`` – storage backend name
150
+ * ``ASH_CONNECTION_STRING`` – database connection URI
151
+ * ``ASH_SHARED_SECRET`` – encryption passphrase
152
+ * ``ASH_ENCRYPT_KEYS`` – ``true`` / ``false``
153
+ * ``ASH_ROTATION_STRATEGY`` – rotation algorithm name
154
+ * ``ASH_AUTO_RESET_COUNTERS`` – ``true`` / ``false``
155
+ * ``ASH_SOFT_DELETE`` – ``true`` / ``false``
156
+ * ``ASH_DEFAULT_DAILY_LIMIT``
157
+ * ``ASH_DEFAULT_MONTHLY_LIMIT``
158
+ * ``ASH_DEFAULT_MAX_CONCURRENT``
159
+
160
+ Returns
161
+ -------
162
+ ASHConfig
163
+ A fully populated configuration instance.
164
+ """
165
+ return ASHConfig(
166
+ storage_backend=os.environ.get("ASH_STORAGE_BACKEND", "memory"),
167
+ connection_string=os.environ.get("ASH_CONNECTION_STRING", ""),
168
+ shared_secret=os.environ.get("ASH_SHARED_SECRET", ""),
169
+ encrypt_keys=_env_bool("ASH_ENCRYPT_KEYS", default=True),
170
+ rotation_strategy=os.environ.get("ASH_ROTATION_STRATEGY", "round_robin"),
171
+ auto_reset_counters=_env_bool("ASH_AUTO_RESET_COUNTERS", default=True),
172
+ soft_delete=_env_bool("ASH_SOFT_DELETE", default=True),
173
+ default_daily_limit=_env_optional_int("ASH_DEFAULT_DAILY_LIMIT"),
174
+ default_monthly_limit=_env_optional_int("ASH_DEFAULT_MONTHLY_LIMIT"),
175
+ default_max_concurrent=_env_optional_int("ASH_DEFAULT_MAX_CONCURRENT"),
176
+ metadata_indexes=[x.strip() for x in os.environ.get("ASH_METADATA_INDEXES", "").split(",")] if os.environ.get("ASH_METADATA_INDEXES") else [],
177
+ )
@@ -0,0 +1,238 @@
1
+ """AES-256-GCM encryption utilities for the API Service Handler.
2
+
3
+ API key values are encrypted at rest using a symmetric key derived from a
4
+ shared secret. The encrypted representation is a dot-separated triple of
5
+ Base-64 segments::
6
+
7
+ <iv_b64>.<auth_tag_b64>.<ciphertext_b64>
8
+
9
+ This format is deterministic enough to be detected by :func:`is_encrypted`
10
+ yet safe against accidental double-encryption because plain API keys never
11
+ contain two dots separating valid Base-64 segments.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import base64
17
+ import hashlib
18
+ import json
19
+ import os
20
+ import re
21
+ from typing import Any
22
+
23
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
24
+
25
+ from .exceptions import EncryptionError
26
+
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Constants
30
+ # ---------------------------------------------------------------------------
31
+
32
+ _IV_BYTES: int = 12 # 96-bit nonce recommended for AES-GCM
33
+ _TAG_BYTES: int = 16 # 128-bit authentication tag
34
+ _KEY_BYTES: int = 32 # 256-bit key (AES-256)
35
+
36
+ # Loose regex for a Base-64 segment (standard or URL-safe, with optional padding)
37
+ _B64_SEGMENT = r"[A-Za-z0-9+/\-_]+=*"
38
+ _ENCRYPTED_PATTERN: re.Pattern[str] = re.compile(
39
+ rf"^{_B64_SEGMENT}\.{_B64_SEGMENT}\.{_B64_SEGMENT}$"
40
+ )
41
+
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # Internal helpers
45
+ # ---------------------------------------------------------------------------
46
+
47
+
48
+ def _derive_key(shared_secret: str) -> bytes:
49
+ """Derive a 256-bit AES key from *shared_secret* via SHA-256.
50
+
51
+ Parameters
52
+ ----------
53
+ shared_secret:
54
+ The passphrase to derive the key from. Must be non-empty.
55
+
56
+ Returns
57
+ -------
58
+ bytes
59
+ A 32-byte key suitable for ``AESGCM``.
60
+
61
+ Raises
62
+ ------
63
+ ValueError
64
+ If *shared_secret* is empty.
65
+ """
66
+ if not shared_secret:
67
+ raise ValueError("shared_secret must not be empty for encryption/decryption.")
68
+ return hashlib.sha256(shared_secret.encode("utf-8")).digest()
69
+
70
+
71
+ def _b64_encode(data: bytes) -> str:
72
+ """Return standard Base-64 encoding of *data* as a UTF-8 string."""
73
+ return base64.b64encode(data).decode("utf-8")
74
+
75
+
76
+ def _b64_decode(data: str) -> bytes:
77
+ """Decode a standard Base-64 string to bytes."""
78
+ return base64.b64decode(data)
79
+
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # Public API – encrypt / decrypt
83
+ # ---------------------------------------------------------------------------
84
+
85
+
86
+ def encrypt_api_key(plain_text: str, shared_secret: str) -> str:
87
+ """Encrypt *plain_text* with AES-256-GCM using a key derived from *shared_secret*.
88
+
89
+ Parameters
90
+ ----------
91
+ plain_text:
92
+ The raw API key (or any secret) to encrypt.
93
+ shared_secret:
94
+ Passphrase used to derive the encryption key.
95
+
96
+ Returns
97
+ -------
98
+ str
99
+ The encrypted payload formatted as ``iv_b64.auth_tag_b64.ciphertext_b64``.
100
+
101
+ Raises
102
+ ------
103
+ ValueError
104
+ If *shared_secret* is empty.
105
+ """
106
+ key = _derive_key(shared_secret)
107
+ iv = os.urandom(_IV_BYTES)
108
+
109
+ aesgcm = AESGCM(key)
110
+ # AESGCM.encrypt returns ciphertext || tag (tag is the last 16 bytes)
111
+ ciphertext_with_tag: bytes = aesgcm.encrypt(iv, plain_text.encode("utf-8"), None)
112
+
113
+ # Split into ciphertext body + authentication tag
114
+ ciphertext = ciphertext_with_tag[:-_TAG_BYTES]
115
+ auth_tag = ciphertext_with_tag[-_TAG_BYTES:]
116
+
117
+ return f"{_b64_encode(iv)}.{_b64_encode(auth_tag)}.{_b64_encode(ciphertext)}"
118
+
119
+
120
+ def decrypt_api_key(encrypted_str: str, shared_secret: str) -> str:
121
+ """Decrypt an encrypted payload produced by :func:`encrypt_api_key`.
122
+
123
+ Values that do not look like an encrypted triple (non-string, or fewer/more
124
+ than three dot-separated segments) are returned unchanged, so plain-text
125
+ keys pass through safely.
126
+
127
+ Values that *do* look encrypted but fail decryption (e.g. wrong secret,
128
+ tampered ciphertext) raise :class:`EncryptionError` — callers should not
129
+ silently swallow decryption failures, as they indicate a misconfiguration
130
+ or data integrity problem.
131
+
132
+ Parameters
133
+ ----------
134
+ encrypted_str:
135
+ The dot-separated encrypted payload, or a plain-text fallback.
136
+ shared_secret:
137
+ Passphrase used to derive the decryption key.
138
+
139
+ Returns
140
+ -------
141
+ str
142
+ The decrypted plain text.
143
+
144
+ Raises
145
+ ------
146
+ EncryptionError
147
+ If the value looks encrypted but decryption fails.
148
+ """
149
+ # Guard: not a string → return as-is
150
+ if not isinstance(encrypted_str, str):
151
+ return encrypted_str # type: ignore[return-value]
152
+
153
+ # Guard: must be exactly three dot-separated segments to look encrypted
154
+ parts = encrypted_str.split(".")
155
+ if len(parts) != 3:
156
+ return encrypted_str
157
+
158
+ # Value matches the encrypted format — decrypt and raise on any failure.
159
+ try:
160
+ iv = _b64_decode(parts[0])
161
+ auth_tag = _b64_decode(parts[1])
162
+ ciphertext = _b64_decode(parts[2])
163
+
164
+ key = _derive_key(shared_secret)
165
+ aesgcm = AESGCM(key)
166
+
167
+ # Reconstruct the format AESGCM expects: ciphertext || tag
168
+ ciphertext_with_tag = ciphertext + auth_tag
169
+ decrypted_bytes: bytes = aesgcm.decrypt(iv, ciphertext_with_tag, None)
170
+
171
+ # Try to deserialise as JSON first (handles stored dicts / lists)
172
+ try:
173
+ result: Any = json.loads(decrypted_bytes)
174
+ return str(result) if not isinstance(result, str) else result
175
+ except (json.JSONDecodeError, UnicodeDecodeError):
176
+ return decrypted_bytes.decode("utf-8")
177
+
178
+ except EncryptionError:
179
+ raise
180
+ except Exception as exc:
181
+ raise EncryptionError(
182
+ f"Decryption failed — verify shared_secret matches the one used for encryption: {exc}"
183
+ ) from exc
184
+
185
+
186
+ # ---------------------------------------------------------------------------
187
+ # Public API – inspection helpers
188
+ # ---------------------------------------------------------------------------
189
+
190
+
191
+ def is_encrypted(value: str) -> bool:
192
+ """Check whether *value* looks like an encrypted payload.
193
+
194
+ This is a *heuristic* based on the ``iv.tag.ciphertext`` format — three
195
+ dot-separated Base-64 segments — and does **not** attempt actual
196
+ decryption.
197
+
198
+ Parameters
199
+ ----------
200
+ value:
201
+ The string to inspect.
202
+
203
+ Returns
204
+ -------
205
+ bool
206
+ ``True`` if *value* matches the encrypted format.
207
+ """
208
+ if not isinstance(value, str):
209
+ return False
210
+ return bool(_ENCRYPTED_PATTERN.match(value))
211
+
212
+
213
+ def mask_key(key_value: str, show: int = 8) -> str:
214
+ """Return a masked representation of *key_value* suitable for display.
215
+
216
+ Parameters
217
+ ----------
218
+ key_value:
219
+ The API key (plain text or encrypted) to mask.
220
+ show:
221
+ Number of leading characters to reveal. Defaults to ``8``.
222
+
223
+ Returns
224
+ -------
225
+ str
226
+ The first *show* characters followed by ``***``, or the full value
227
+ if it is shorter than or equal to *show* characters.
228
+
229
+ Examples
230
+ --------
231
+ >>> mask_key("sk-abc123def456ghi789")
232
+ 'sk-abc12***'
233
+ >>> mask_key("short")
234
+ 'short'
235
+ """
236
+ if not isinstance(key_value, str) or len(key_value) <= show:
237
+ return key_value
238
+ return key_value[:show] + "***"
@@ -0,0 +1,217 @@
1
+ """Enumerations for the api-service-handler library.
2
+
3
+ Defines all enum types used across the library including API providers,
4
+ key statuses, storage backends, rotation strategies, and environments.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from enum import Enum
10
+
11
+
12
+ class Provider(str, Enum):
13
+ """Supported API service providers.
14
+
15
+ Covers a wide range of provider categories including AI/LLM, speech/audio,
16
+ cloud infrastructure, communication, payments, search/data, auth,
17
+ storage/CDN, dev tools, messaging, productivity, monitoring, and maps.
18
+
19
+ Use ``Provider.from_string()`` for case-insensitive lookup with automatic
20
+ fallback to ``CUSTOM`` for unrecognised values.
21
+ """
22
+
23
+ # --- AI / LLM ---
24
+ OPENAI = "openai"
25
+ ANTHROPIC = "anthropic"
26
+ GOOGLE_GEMINI = "google_gemini"
27
+ GOOGLE_VERTEX = "google_vertex"
28
+ MISTRAL = "mistral"
29
+ COHERE = "cohere"
30
+ HUGGINGFACE = "huggingface"
31
+ REPLICATE = "replicate"
32
+ TOGETHER_AI = "together_ai"
33
+ GROQ = "groq"
34
+ FIREWORKS = "fireworks"
35
+ DEEPSEEK = "deepseek"
36
+ XAI = "xai"
37
+ PERPLEXITY = "perplexity"
38
+ OPENROUTER = "openrouter"
39
+ LEMOFOX = "lemofox"
40
+
41
+ # --- Speech / Audio ---
42
+ DEEPGRAM = "deepgram"
43
+ ELEVEN_LABS = "eleven_labs"
44
+ ASSEMBLY_AI = "assembly_ai"
45
+ WHISPER = "whisper"
46
+
47
+ # --- Cloud ---
48
+ AWS = "aws"
49
+ AZURE = "azure"
50
+ GCP = "gcp"
51
+ CLOUDFLARE = "cloudflare"
52
+ DIGITAL_OCEAN = "digital_ocean"
53
+ VERCEL = "vercel"
54
+
55
+ # --- Communication ---
56
+ TWILIO = "twilio"
57
+ SENDGRID = "sendgrid"
58
+ MAILGUN = "mailgun"
59
+ RESEND = "resend"
60
+ POSTMARK = "postmark"
61
+
62
+ # --- Payments ---
63
+ STRIPE = "stripe"
64
+ RAZORPAY = "razorpay"
65
+ PAYPAL = "paypal"
66
+ LEMONSQUEEZY = "lemonsqueezy"
67
+
68
+ # --- Search / Data ---
69
+ SERP_API = "serp_api"
70
+ BING_SEARCH = "bing_search"
71
+ ALGOLIA = "algolia"
72
+ PINECONE = "pinecone"
73
+ WEAVIATE = "weaviate"
74
+
75
+ # --- Auth ---
76
+ AUTH0 = "auth0"
77
+ CLERK = "clerk"
78
+ FIREBASE = "firebase"
79
+ SUPABASE_AUTH = "supabase_auth"
80
+
81
+ # --- Storage / CDN ---
82
+ CLOUDINARY = "cloudinary"
83
+ S3 = "s3"
84
+ SUPABASE = "supabase"
85
+ R2 = "r2"
86
+ UPLOADTHING = "uploadthing"
87
+
88
+ # --- Dev Tools ---
89
+ GITHUB = "github"
90
+ GITLAB = "gitlab"
91
+ BITBUCKET = "bitbucket"
92
+ LINEAR = "linear"
93
+ JIRA = "jira"
94
+
95
+ # --- Messaging ---
96
+ SLACK = "slack"
97
+ DISCORD = "discord"
98
+ TELEGRAM = "telegram"
99
+ WHATSAPP = "whatsapp"
100
+
101
+ # --- Productivity ---
102
+ NOTION = "notion"
103
+ AIRTABLE = "airtable"
104
+ ZAPIER = "zapier"
105
+ MAKE = "make"
106
+
107
+ # --- Monitoring ---
108
+ SENTRY = "sentry"
109
+ DATADOG = "datadog"
110
+ NEW_RELIC = "new_relic"
111
+ LOGFLARE = "logflare"
112
+
113
+ # --- Maps ---
114
+ GOOGLE_MAPS = "google_maps"
115
+ MAPBOX = "mapbox"
116
+ HERE = "here"
117
+
118
+ # --- Catch-all ---
119
+ CUSTOM = "custom"
120
+
121
+ @classmethod
122
+ def from_string(cls, value: str) -> Provider:
123
+ """Look up a provider by name (case-insensitive).
124
+
125
+ Attempts to match *value* against both the enum member **name**
126
+ (e.g. ``"OPENAI"``) and the enum member **value** (e.g. ``"openai"``).
127
+ Returns :attr:`Provider.CUSTOM` when no match is found.
128
+
129
+ Args:
130
+ value: The provider string to look up.
131
+
132
+ Returns:
133
+ The matching ``Provider`` member, or ``Provider.CUSTOM``.
134
+
135
+ Examples:
136
+ >>> Provider.from_string("openai")
137
+ <Provider.OPENAI: 'openai'>
138
+ >>> Provider.from_string("ANTHROPIC")
139
+ <Provider.ANTHROPIC: 'anthropic'>
140
+ >>> Provider.from_string("unknown_service")
141
+ <Provider.CUSTOM: 'custom'>
142
+ """
143
+ normalised = value.strip().upper()
144
+
145
+ # Fast path: match by member name (e.g. "OPENAI", "GOOGLE_GEMINI").
146
+ try:
147
+ return cls[normalised]
148
+ except KeyError:
149
+ pass
150
+
151
+ # Slower path: match by member value (lowercase form).
152
+ lower = value.strip().lower()
153
+ for member in cls:
154
+ if member.value == lower:
155
+ return member
156
+
157
+ return cls.CUSTOM
158
+
159
+
160
+ class KeyStatus(str, Enum):
161
+ """Status of an API key in the key pool."""
162
+
163
+ ACTIVE = "active"
164
+ """Key is available for use."""
165
+
166
+ INACTIVE = "inactive"
167
+ """Key has been manually disabled."""
168
+
169
+ RATE_LIMITED = "rate_limited"
170
+ """Key has hit a rate limit and is temporarily unavailable."""
171
+
172
+ EXPIRED = "expired"
173
+ """Key has passed its expiration date."""
174
+
175
+ REVOKED = "revoked"
176
+ """Key has been permanently revoked by the provider or admin."""
177
+
178
+
179
+ class StorageBackend(str, Enum):
180
+ """Supported storage backends for persisting key data."""
181
+
182
+ MEMORY = "memory"
183
+ """In-process dictionary — useful for testing and single-process apps."""
184
+
185
+ SQLITE = "sqlite"
186
+ """SQLite file-based database."""
187
+
188
+ MONGODB = "mongodb"
189
+ """MongoDB document store."""
190
+
191
+ POSTGRESQL = "postgresql"
192
+ """PostgreSQL relational database."""
193
+
194
+
195
+ class RotationStrategy(str, Enum):
196
+ """Strategy for selecting the next API key from the pool."""
197
+
198
+ ROUND_ROBIN = "round_robin"
199
+ """Cycle through keys in order."""
200
+
201
+ LEAST_USED = "least_used"
202
+ """Pick the key with the fewest total uses."""
203
+
204
+ RANDOM = "random"
205
+ """Pick a key at random."""
206
+
207
+ WEIGHTED = "weighted"
208
+ """Pick a key based on assigned weights / priorities."""
209
+
210
+
211
+ class Environment(str, Enum):
212
+ """Application deployment environment."""
213
+
214
+ PRODUCTION = "production"
215
+ STAGING = "staging"
216
+ DEVELOPMENT = "development"
217
+ TESTING = "testing"