redenv 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.
redenv/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ from .client import Redenv
2
+ from .errors import RedenvError
3
+ from .secrets import Secrets
4
+ from .sync import Redenv as RedenvSync
5
+
6
+ __version__ = "0.2.0"
7
+ __all__ = ["Redenv", "RedenvSync", "RedenvError", "Secrets"]
redenv/client.py ADDED
@@ -0,0 +1,137 @@
1
+ import asyncio
2
+ import time
3
+ from typing import Dict, Any, Optional, Literal
4
+ from upstash_redis.asyncio import Redis
5
+ from cachetools import LRUCache
6
+ from .types import RedenvOptions, CacheEntry
7
+ from .secrets import Secrets
8
+ from .utils import fetch_and_decrypt, populate_env, log, error, set_secret, get_secret_version
9
+ from .errors import RedenvError
10
+
11
+ class Redenv:
12
+ def __init__(self, options: Dict[str, Any]):
13
+ self.options = RedenvOptions.from_dict(options)
14
+ self.validate_options()
15
+
16
+ self._cache = LRUCache(maxsize=1000)
17
+
18
+ self.redis = Redis(
19
+ url=self.options.upstash.url,
20
+ token=self.options.upstash.token
21
+ )
22
+
23
+ def validate_options(self):
24
+ if not self.options.project:
25
+ raise RedenvError("Missing required configuration option: project", "MISSING_CONFIG")
26
+ if not self.options.token_id:
27
+ raise RedenvError("Missing required configuration option: token_id", "MISSING_CONFIG")
28
+ if not self.options.token:
29
+ raise RedenvError("Missing required configuration option: token", "MISSING_CONFIG")
30
+ if not self.options.upstash.url or not self.options.upstash.token:
31
+ raise RedenvError("Missing required configuration option: upstash", "MISSING_CONFIG")
32
+
33
+ def _get_cache_key(self) -> str:
34
+ return f"redenv:{self.options.project}:{self.options.environment}"
35
+
36
+ async def _get_secrets(self) -> Secrets:
37
+ key = self._get_cache_key()
38
+ entry = self._cache.get(key)
39
+ now = time.time()
40
+
41
+ ttl_seconds = self.options.cache.ttl
42
+ swr_seconds = self.options.cache.swr
43
+
44
+ # Function to fetch fresh value
45
+ async def fetch_fresh() -> Secrets:
46
+ try:
47
+ log("Fetching fresh secrets...", self.options.log)
48
+ secrets = await fetch_and_decrypt(self.redis, self.options)
49
+
50
+ # Update cache with new entry
51
+ self._cache[key] = CacheEntry(secrets, time.time())
52
+
53
+ # Side effect: populate environment
54
+ await populate_env(secrets, self.options)
55
+
56
+ return secrets
57
+ except Exception as e:
58
+ error(f"Failed to fetch secrets: {e}", self.options.log)
59
+ raise e
60
+
61
+ if entry:
62
+ age = now - entry.created_at
63
+
64
+ if age < ttl_seconds:
65
+ # Case 1: Fresh
66
+ log("Cache hit (Fresh).", self.options.log)
67
+ return entry.value
68
+
69
+ elif age < (ttl_seconds + swr_seconds):
70
+ # Case 2: Stale (SWR)
71
+ log("Cache hit (Stale). Revalidating in background...", self.options.log)
72
+ # Return stale value immediately
73
+ # Spawn background refresh
74
+ asyncio.create_task(fetch_fresh())
75
+ return entry.value
76
+ else:
77
+ # Case 3: Expired
78
+ log("Cache expired. Fetching fresh...", self.options.log)
79
+ return await fetch_fresh()
80
+ else:
81
+ # Case 4: Miss
82
+ log("Cache miss. Fetching fresh...", self.options.log)
83
+ return await fetch_fresh()
84
+
85
+ async def init(self):
86
+ """
87
+ Initializes the environment with secrets.
88
+ Alias for load().
89
+ """
90
+ await self.load()
91
+
92
+ async def load(self) -> Secrets:
93
+ """
94
+ Fetches, caches, and injects secrets into the environment.
95
+
96
+ Returns:
97
+ The Secrets object.
98
+ """
99
+ secrets = await self._get_secrets()
100
+
101
+ # Ensure env is populated
102
+ await populate_env(secrets, self.options)
103
+
104
+ return secrets
105
+
106
+ async def set(self, key: str, value: str):
107
+ """
108
+ Adds or updates a secret.
109
+ """
110
+ if not key or not value:
111
+ raise RedenvError("Key and value are required.", "INVALID_INPUT")
112
+
113
+ try:
114
+ await set_secret(self.redis, self.options, key, value)
115
+ log(f'Successfully set secret for key "{key}".', self.options.log)
116
+
117
+ # Invalidate cache
118
+ cache_key = self._get_cache_key()
119
+ if cache_key in self._cache:
120
+ del self._cache[cache_key]
121
+
122
+ except Exception as e:
123
+ msg = str(e)
124
+ error(f"Failed to set secret: {msg}", self.options.log)
125
+ raise RedenvError(f"Failed to set secret: {msg}", "UNKNOWN_ERROR")
126
+
127
+ async def get_version(self, key: str, version: int, mode: Literal["id", "index"] = "id") -> Optional[str]:
128
+ """
129
+ Fetches a specific version of a secret with caching.
130
+
131
+ Args:
132
+ key: The secret key.
133
+ version: The version ID or index.
134
+ mode: "id" (default) uses positive version numbers, negative for index from end.
135
+ "index" treats version as a 0-based array index (0=latest).
136
+ """
137
+ return await get_secret_version(self.redis, self.options, self._cache, key, version, mode)
redenv/crypto.py ADDED
@@ -0,0 +1,71 @@
1
+ import os
2
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
3
+ from cryptography.hazmat.primitives import hashes
4
+ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
5
+ from .errors import RedenvError
6
+
7
+ # --- Configuration ---
8
+ IV_LENGTH = 12
9
+ KEY_LENGTH = 32 # 256 bits = 32 bytes
10
+ PBKDF2_ITERATIONS = 310000
11
+ SALT_LENGTH = 16
12
+
13
+ def buffer_to_hex(buffer: bytes) -> str:
14
+ return buffer.hex()
15
+
16
+ def hex_to_buffer(hex_str: str) -> bytes:
17
+ return bytes.fromhex(hex_str)
18
+
19
+ def random_bytes(length: int = 32) -> bytes:
20
+ return os.urandom(length)
21
+
22
+ def generate_salt() -> bytes:
23
+ return random_bytes(SALT_LENGTH)
24
+
25
+ def derive_key(password: str, salt: bytes) -> bytes:
26
+ """
27
+ Derives an encryption key from a password and salt using PBKDF2.
28
+ """
29
+ kdf = PBKDF2HMAC(
30
+ algorithm=hashes.SHA256(),
31
+ length=KEY_LENGTH,
32
+ salt=salt,
33
+ iterations=PBKDF2_ITERATIONS,
34
+ )
35
+ return kdf.derive(password.encode('utf-8'))
36
+
37
+ def encrypt(data: str, key: bytes) -> str:
38
+ """
39
+ Encrypts data using AES-256-GCM.
40
+ Returns a string containing the iv and the ciphertext, separated by a dot.
41
+ """
42
+ iv = os.urandom(IV_LENGTH)
43
+ aesgcm = AESGCM(key)
44
+ ciphertext = aesgcm.encrypt(iv, data.encode('utf-8'), None)
45
+
46
+ return f"{buffer_to_hex(iv)}.{buffer_to_hex(ciphertext)}"
47
+
48
+ def decrypt(encrypted_string: str, key: bytes) -> str:
49
+ """
50
+ Decrypts data that was encrypted with the `encrypt` function.
51
+ """
52
+ if not encrypted_string:
53
+ raise RedenvError("Encrypted string cannot be empty.", "UNKNOWN_ERROR")
54
+
55
+ parts = encrypted_string.split(".")
56
+ if len(parts) != 2 or not parts[0] or not parts[1]:
57
+ raise RedenvError("Invalid encrypted string format.", "UNKNOWN_ERROR")
58
+
59
+ try:
60
+ iv = hex_to_buffer(parts[0])
61
+ ciphertext = hex_to_buffer(parts[1])
62
+
63
+ aesgcm = AESGCM(key)
64
+ decrypted_data = aesgcm.decrypt(iv, ciphertext, None)
65
+
66
+ return decrypted_data.decode('utf-8')
67
+ except Exception:
68
+ raise RedenvError(
69
+ "Decryption failed. This likely means an incorrect password was used or the data is corrupted.",
70
+ "DECRYPTION_FAILED"
71
+ )
redenv/errors.py ADDED
@@ -0,0 +1,6 @@
1
+ class RedenvError(Exception):
2
+ """Base exception for Redenv."""
3
+ def __init__(self, message: str, code: str = "UNKNOWN_ERROR"):
4
+ self.message = message
5
+ self.code = code
6
+ super().__init__(f"[{code}] {message}")
redenv/py.typed ADDED
File without changes
redenv/secrets.py ADDED
@@ -0,0 +1,95 @@
1
+ import json
2
+ from typing import Any, Callable, Optional, Type, TypeVar, Union
3
+ from .errors import RedenvError
4
+
5
+ T = TypeVar("T")
6
+
7
+ class Secrets(dict):
8
+ """
9
+ A specialized dictionary for managing decrypted secrets with
10
+ type-casting, scoping, and validation capabilities.
11
+ """
12
+
13
+ def get(self, key: str, default: Any = None, cast: Optional[Union[Type[T], Callable[[Any], T]]] = None) -> Union[T, Any]:
14
+ """
15
+ Retrieves a secret and optionally casts it to a specific type.
16
+ """
17
+ value = super().get(key)
18
+
19
+ if value is None:
20
+ return default
21
+
22
+ if cast is None:
23
+ return value
24
+
25
+ try:
26
+ # Special handling for boolean strings
27
+ if cast is bool:
28
+ if isinstance(value, bool):
29
+ return value
30
+ if isinstance(value, str):
31
+ return value.lower() in ("true", "1", "yes", "on", "t")
32
+ return bool(value)
33
+
34
+ # Special handling for JSON types (dict/list)
35
+ if (cast is dict or cast is list) and isinstance(value, str):
36
+ try:
37
+ return json.loads(value)
38
+ except json.JSONDecodeError:
39
+ return default
40
+
41
+ # General casting (int, float, or custom callable)
42
+ return cast(value) # type: ignore
43
+
44
+ except (ValueError, TypeError):
45
+ return default
46
+
47
+ def __getitem__(self, key: str) -> Any:
48
+ """
49
+ Ensures standard dict access still works but raises a
50
+ helpful error if the key is missing.
51
+ """
52
+ try:
53
+ return super().__getitem__(key)
54
+ except KeyError:
55
+ raise KeyError(f"Secret '{key}' not found in Redenv.")
56
+
57
+ def scope(self, prefix: str) -> "Secrets":
58
+ """
59
+ Returns a new Secrets object containing only the keys that start with
60
+ the given prefix. The prefix is stripped from the keys in the new object.
61
+ """
62
+ subset = Secrets()
63
+ for key, value in self.items():
64
+ if key.startswith(prefix):
65
+ new_key = key[len(prefix):]
66
+ if new_key:
67
+ subset[new_key] = value
68
+ return subset
69
+
70
+ def require(self, *keys: str) -> "Secrets":
71
+ """
72
+ Validates that all provided keys exist.
73
+ Raises RedenvError if any key is missing.
74
+ Returns self for chaining.
75
+ """
76
+ missing = [k for k in keys if k not in self]
77
+ if missing:
78
+ raise RedenvError(
79
+ f"Missing required secrets: {', '.join(missing)}",
80
+ "SECRET_NOT_FOUND"
81
+ )
82
+ return self
83
+
84
+ def __repr__(self) -> str:
85
+ """
86
+ Masks secret values to prevent accidental leakage in logs.
87
+ """
88
+ masked = {k: "********" for k in self.keys()}
89
+ return f"Secrets({masked})"
90
+
91
+ def __str__(self) -> str:
92
+ """
93
+ Returns the masked representation of the secrets.
94
+ """
95
+ return self.__repr__()
@@ -0,0 +1,3 @@
1
+ from .client import Redenv
2
+
3
+ __all__ = ["Redenv"]
redenv/sync/client.py ADDED
@@ -0,0 +1,131 @@
1
+ import time
2
+ from typing import Dict, Any, Optional, Literal
3
+ from upstash_redis import Redis
4
+ from cachetools import LRUCache
5
+ from ..types import RedenvOptions
6
+ from ..secrets import Secrets
7
+ from .utils import fetch_and_decrypt, populate_env, log, error, set_secret, get_secret_version
8
+ from ..errors import RedenvError
9
+
10
+ class CacheEntry:
11
+ def __init__(self, value: Any, created_at: float):
12
+ self.value = value
13
+ self.created_at = created_at
14
+
15
+ class Redenv:
16
+ def __init__(self, options: Dict[str, Any]):
17
+ self.options = RedenvOptions.from_dict(options)
18
+ self.validate_options()
19
+
20
+ self._cache = LRUCache(maxsize=1000)
21
+
22
+ self.redis = Redis(
23
+ url=self.options.upstash.url,
24
+ token=self.options.upstash.token
25
+ )
26
+
27
+ def validate_options(self):
28
+ if not self.options.project:
29
+ raise RedenvError("Missing required configuration option: project", "MISSING_CONFIG")
30
+ if not self.options.token_id:
31
+ raise RedenvError("Missing required configuration option: token_id", "MISSING_CONFIG")
32
+ if not self.options.token:
33
+ raise RedenvError("Missing required configuration option: token", "MISSING_CONFIG")
34
+ if not self.options.upstash.url or not self.options.upstash.token:
35
+ raise RedenvError("Missing required configuration option: upstash", "MISSING_CONFIG")
36
+
37
+ def _get_cache_key(self) -> str:
38
+ return f"redenv:{self.options.project}:{self.options.environment}"
39
+
40
+ def _get_secrets(self) -> Secrets:
41
+ key = self._get_cache_key()
42
+ entry = self._cache.get(key)
43
+ now = time.time()
44
+
45
+ ttl_seconds = self.options.cache.ttl
46
+ swr_seconds = self.options.cache.swr
47
+
48
+ def fetch_fresh() -> Secrets:
49
+ try:
50
+ log("Fetching fresh secrets...", self.options.log)
51
+ secrets = fetch_and_decrypt(self.redis, self.options)
52
+ self._cache[key] = CacheEntry(secrets, time.time())
53
+ populate_env(secrets, self.options)
54
+ return secrets
55
+ except Exception as e:
56
+ error(f"Failed to fetch secrets: {e}", self.options.log)
57
+ raise e
58
+
59
+ if entry:
60
+ age = now - entry.created_at
61
+
62
+ if age < ttl_seconds:
63
+ log("Cache hit (Fresh).", self.options.log)
64
+ return entry.value
65
+
66
+ elif age < (ttl_seconds + swr_seconds):
67
+ # In Sync mode, we can't easily background refresh without threads.
68
+ # Option 1: Block and refresh (Safe)
69
+ # Option 2: Return stale (Fast, but never updates)
70
+ # We choose Option 1 for correctness in Sync scripts.
71
+ log("Cache stale. Refreshing (Blocking)...", self.options.log)
72
+ return fetch_fresh()
73
+ else:
74
+ log("Cache expired. Fetching fresh...", self.options.log)
75
+ return fetch_fresh()
76
+ else:
77
+ log("Cache miss. Fetching fresh...", self.options.log)
78
+ return fetch_fresh()
79
+
80
+ def init(self):
81
+ """
82
+ Initializes the environment with secrets.
83
+ Alias for load().
84
+ """
85
+ self.load()
86
+
87
+ def load(self) -> Secrets:
88
+ """
89
+ Fetches, caches, and injects secrets into the environment.
90
+
91
+ Args:
92
+ override: If True (default), overwrites existing environment variables.
93
+ """
94
+ secrets = self._get_secrets()
95
+
96
+ # Ensure env is populated
97
+ populate_env(secrets, self.options)
98
+
99
+ return secrets
100
+
101
+ def set(self, key: str, value: str):
102
+ """
103
+ Adds or updates a secret.
104
+ """
105
+ if not key or not value:
106
+ raise RedenvError("Key and value are required.", "INVALID_INPUT")
107
+
108
+ try:
109
+ set_secret(self.redis, self.options, key, value)
110
+ log(f'Successfully set secret for key "{key}".', self.options.log)
111
+
112
+ cache_key = self._get_cache_key()
113
+ if cache_key in self._cache:
114
+ del self._cache[cache_key]
115
+
116
+ except Exception as e:
117
+ msg = str(e)
118
+ error(f"Failed to set secret: {msg}", self.options.log)
119
+ raise RedenvError(f"Failed to set secret: {msg}", "UNKNOWN_ERROR")
120
+
121
+ def get_version(self, key: str, version: int, mode: Literal["id", "index"] = "id") -> Optional[str]:
122
+ """
123
+ Fetches a specific version of a secret.
124
+
125
+ Args:
126
+ key: The secret key.
127
+ version: The version ID or index.
128
+ mode: "id" (default) uses positive version numbers, negative for index from end.
129
+ "index" treats version as a 0-based array index (0=latest).
130
+ """
131
+ return get_secret_version(self.redis, self.options, self._cache, key, version, mode)
redenv/sync/utils.py ADDED
@@ -0,0 +1,199 @@
1
+ import json
2
+ import os
3
+ import time
4
+ from typing import Dict, Optional, Any, Union, Literal
5
+ from upstash_redis import Redis as SyncRedis
6
+ from ..crypto import derive_key, decrypt, hex_to_buffer, encrypt
7
+ from ..types import RedenvOptions, CacheEntry
8
+ from ..errors import RedenvError
9
+ from ..secrets import Secrets
10
+ from ..utils import log, error
11
+ from cachetools import LRUCache
12
+
13
+ def get_pek(redis: SyncRedis, options: RedenvOptions, metadata: Optional[Dict[str, Any]] = None) -> bytes:
14
+ """
15
+ Fetches and decrypts the Project Encryption Key (PEK).
16
+ """
17
+ if not metadata:
18
+ meta_key = f"meta@{options.project}"
19
+ metadata = redis.hgetall(meta_key)
20
+
21
+ if not metadata:
22
+ raise RedenvError(f'Project "{options.project}" not found.', "PROJECT_NOT_FOUND")
23
+
24
+ service_tokens = metadata.get("serviceTokens")
25
+ if isinstance(service_tokens, str):
26
+ service_tokens = json.loads(service_tokens)
27
+
28
+ token_info = service_tokens.get(options.token_id) if service_tokens else None
29
+
30
+ if not token_info:
31
+ ephemeral_field = f"ephemeral:{options.token_id}"
32
+ raw_ephemeral = metadata.get(ephemeral_field)
33
+ if raw_ephemeral:
34
+ token_info = json.loads(raw_ephemeral) if isinstance(raw_ephemeral, str) else raw_ephemeral
35
+
36
+ if not token_info:
37
+ raise RedenvError("Invalid Redenv Token ID.", "INVALID_TOKEN_ID")
38
+
39
+ salt = hex_to_buffer(token_info["salt"])
40
+ token_key = derive_key(options.token, salt)
41
+
42
+ decrypted_pek_hex = decrypt(token_info["encryptedPEK"], token_key)
43
+
44
+ return hex_to_buffer(decrypted_pek_hex)
45
+
46
+ def fetch_and_decrypt(redis: SyncRedis, options: RedenvOptions) -> Secrets:
47
+ """
48
+ Fetches all secrets for a given environment and decrypts them.
49
+ """
50
+ log("Expired Cache: Fetching secrets from source...", options.log, "high")
51
+
52
+ try:
53
+ pek = get_pek(redis, options)
54
+ except Exception as e:
55
+ error(f"Failed to get PEK: {e}", options.log)
56
+ raise e
57
+
58
+ env_key = f"{options.environment}:{options.project}"
59
+ versioned_secrets = redis.hgetall(env_key)
60
+
61
+ secrets = Secrets()
62
+ if not versioned_secrets:
63
+ log("No secrets found for this environment.", options.log)
64
+ return secrets
65
+
66
+ for key, history_str in versioned_secrets.items():
67
+ if key.startswith("__"):
68
+ continue
69
+
70
+ try:
71
+ history = json.loads(history_str) if isinstance(history_str, str) else history_str
72
+ if not isinstance(history, list) or len(history) == 0:
73
+ continue
74
+
75
+ encrypted_value = history[0]["value"]
76
+ decrypted_value = decrypt(encrypted_value, pek)
77
+ secrets[key] = decrypted_value
78
+ except Exception:
79
+ error(f'Failed to decrypt secret "{key}".', options.log)
80
+ continue
81
+
82
+ log(f"Successfully loaded {len(secrets)} secrets.", options.log)
83
+ return secrets
84
+
85
+ def populate_env(secrets: Union[Dict[str, str], Secrets], options: RedenvOptions):
86
+ """
87
+ Injects secrets into the current runtime's environment.
88
+ """
89
+ log("Populating environment with secrets...", options.log)
90
+ injected_count = 0
91
+
92
+ for key, value in secrets.items():
93
+ if not options.env.override and key in os.environ:
94
+ continue
95
+
96
+ os.environ[key] = value
97
+ injected_count += 1
98
+
99
+ log(f"Injection complete. {injected_count} variables were set.", options.log)
100
+
101
+ def set_secret(redis: SyncRedis, options: RedenvOptions, key: str, value: str):
102
+ """
103
+ Sets a secret in Redis with versioning and history.
104
+ """
105
+ env_key = f"{options.environment}:{options.project}"
106
+ meta_key = f"meta@{options.project}"
107
+
108
+ # Sequential fetch (Simpler for sync, parallel requires threads)
109
+ metadata = redis.hgetall(meta_key)
110
+ current_history_str = redis.hget(env_key, key)
111
+
112
+ if not metadata:
113
+ raise RedenvError(f'Project "{options.project}" not found.', "PROJECT_NOT_FOUND")
114
+
115
+ pek = get_pek(redis, options, metadata)
116
+
117
+ history_limit = int(metadata.get("historyLimit", 10))
118
+
119
+ history = []
120
+ if current_history_str:
121
+ history = json.loads(current_history_str) if isinstance(current_history_str, str) else current_history_str
122
+
123
+ if not isinstance(history, list):
124
+ history = []
125
+
126
+ last_version = history[0]["version"] if len(history) > 0 else 0
127
+
128
+ encrypted_value = encrypt(value, pek)
129
+
130
+ from datetime import datetime, timezone
131
+
132
+ new_version = {
133
+ "version": last_version + 1,
134
+ "value": encrypted_value,
135
+ "user": options.token_id,
136
+ "createdAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
137
+ }
138
+
139
+ history.insert(0, new_version)
140
+
141
+ if history_limit > 0:
142
+ history = history[:history_limit]
143
+
144
+ return redis.hset(env_key, key, json.dumps(history))
145
+
146
+ def get_secret_version(redis: SyncRedis, options: RedenvOptions, cache: LRUCache, key: str, version: int, mode: Literal["id", "index"] = "id") -> Optional[str]:
147
+ """
148
+ Fetches a specific version of a secret with optimized caching and smart indexing.
149
+ """
150
+ hist_cache_key = f"history:{options.project}:{options.environment}:{key}"
151
+ entry = cache.get(hist_cache_key)
152
+
153
+ history_list = []
154
+
155
+ if entry:
156
+ log(f"History cache hit for {key}.", options.log)
157
+ history_list = entry.value
158
+ else:
159
+ log(f"History cache miss for {key}. Fetching full history...", options.log)
160
+ env_key = f"{options.environment}:{options.project}"
161
+ history_str = redis.hget(env_key, key)
162
+
163
+ if history_str:
164
+ try:
165
+ raw_list = json.loads(history_str) if isinstance(history_str, str) else history_str
166
+ if isinstance(raw_list, list):
167
+ history_list = raw_list
168
+ cache[hist_cache_key] = CacheEntry(history_list, time.time())
169
+ except Exception as e:
170
+ error(f"Failed to parse history: {e}", options.log)
171
+
172
+ if not history_list:
173
+ return None
174
+
175
+ target_record = None
176
+
177
+ if mode == "index":
178
+ try:
179
+ target_record = history_list[version]
180
+ except IndexError:
181
+ return None
182
+ else:
183
+ if version < 0:
184
+ try:
185
+ target_record = history_list[version]
186
+ except IndexError:
187
+ return None
188
+ else:
189
+ target_record = next((item for item in history_list if item.get("version") == version), None)
190
+
191
+ if not target_record:
192
+ return None
193
+
194
+ try:
195
+ pek = get_pek(redis, options)
196
+ return decrypt(target_record["value"], pek)
197
+ except Exception as e:
198
+ error(f"Failed to decrypt version {version} ({mode}): {e}", options.log)
199
+ return None
redenv/types.py ADDED
@@ -0,0 +1,64 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Literal, Dict, Any
3
+
4
+ LogPreference = Literal["none", "low", "high"]
5
+
6
+ @dataclass
7
+ class UpstashConfig:
8
+ url: str
9
+ token: str
10
+
11
+ @dataclass
12
+ class CacheConfig:
13
+ ttl: int = 300
14
+ swr: int = 86400
15
+
16
+ @dataclass
17
+ class EnvConfig:
18
+ override: bool = True
19
+
20
+ class CacheEntry:
21
+ def __init__(self, value: Any, created_at: float):
22
+ self.value = value
23
+ self.created_at = created_at
24
+
25
+ @dataclass
26
+ class RedenvOptions:
27
+ project: str
28
+ token_id: str
29
+ token: str
30
+ upstash: UpstashConfig
31
+ environment: str = "development"
32
+ cache: CacheConfig = field(default_factory=CacheConfig)
33
+ env: EnvConfig = field(default_factory=EnvConfig)
34
+ log: LogPreference = "low"
35
+
36
+ @classmethod
37
+ def from_dict(cls, data: Dict[str, Any]) -> 'RedenvOptions':
38
+ upstash_data = data.get("upstash", {})
39
+ upstash = UpstashConfig(
40
+ url=upstash_data.get("url", ""),
41
+ token=upstash_data.get("token", "")
42
+ )
43
+
44
+ cache_data = data.get("cache", {})
45
+ cache = CacheConfig(
46
+ ttl=cache_data.get("ttl", 300),
47
+ swr=cache_data.get("swr", 86400)
48
+ )
49
+
50
+ env_data = data.get("env", {})
51
+ env = EnvConfig(
52
+ override=env_data.get("override", True)
53
+ )
54
+
55
+ return cls(
56
+ project=data.get("project", ""),
57
+ token_id=data.get("token_id", data.get("tokenId", "")),
58
+ token=data.get("token", ""),
59
+ upstash=upstash,
60
+ environment=data.get("environment", "development"),
61
+ cache=cache,
62
+ env=env,
63
+ log=data.get("log", "low")
64
+ )
redenv/utils.py ADDED
@@ -0,0 +1,251 @@
1
+ from .crypto import derive_key, decrypt, hex_to_buffer, encrypt
2
+ from .types import RedenvOptions, LogPreference, CacheEntry
3
+ from .errors import RedenvError
4
+ from upstash_redis import AsyncRedis
5
+ from .secrets import Secrets
6
+ import asyncio
7
+ import json
8
+ import os
9
+ import time
10
+ from typing import Literal, Optional, Dict, Any, Union
11
+ from cachetools import LRUCache
12
+
13
+ import logging
14
+
15
+ logger = logging.getLogger("redenv")
16
+
17
+ if not logger.handlers:
18
+ logger.addHandler(logging.NullHandler())
19
+
20
+ def log(message: str, preference: LogPreference = "low", priority: str = "low"):
21
+ """
22
+ Logs messages using the standard python logging module.
23
+ """
24
+ if preference == "none":
25
+ return
26
+
27
+ # If preference is "low", we only log high priority messages as INFO
28
+ # If preference is "high", we log everything (low priority as DEBUG, high as INFO)
29
+
30
+ if priority == "high":
31
+ logger.info(message)
32
+ elif preference == "high":
33
+ logger.debug(message)
34
+
35
+ def error(message: str, preference: LogPreference = "low"):
36
+ """
37
+ Logs errors using the standard python logging module.
38
+ """
39
+ if preference != "none":
40
+ logger.error(message)
41
+
42
+ async def get_pek(redis: AsyncRedis, options: RedenvOptions, metadata: Optional[Dict[str, Any]] = None) -> bytes:
43
+ """
44
+ Fetches and decrypts the Project Encryption Key (PEK).
45
+ If metadata is provided, it skips the Redis fetch.
46
+ """
47
+ if not metadata:
48
+ meta_key = f"meta@{options.project}"
49
+ metadata = await redis.hgetall(meta_key)
50
+
51
+ if not metadata:
52
+ raise RedenvError(f'Project "{options.project}" not found.', "PROJECT_NOT_FOUND")
53
+
54
+ service_tokens = metadata.get("serviceTokens")
55
+ if isinstance(service_tokens, str):
56
+ service_tokens = json.loads(service_tokens)
57
+
58
+ token_info = service_tokens.get(options.token_id) if service_tokens else None
59
+
60
+ # If not found in standard service tokens, check for ephemeral token field
61
+ if not token_info:
62
+ ephemeral_field = f"ephemeral:{options.token_id}"
63
+ raw_ephemeral = metadata.get(ephemeral_field)
64
+ if raw_ephemeral:
65
+ token_info = json.loads(raw_ephemeral) if isinstance(raw_ephemeral, str) else raw_ephemeral
66
+
67
+ if not token_info:
68
+ raise RedenvError("Invalid Redenv Token ID.", "INVALID_TOKEN_ID")
69
+
70
+ salt = hex_to_buffer(token_info["salt"])
71
+ # Note: derive_key in python takes string password and bytes salt
72
+ token_key = derive_key(options.token, salt)
73
+
74
+ decrypted_pek_hex = decrypt(token_info["encryptedPEK"], token_key)
75
+
76
+ return hex_to_buffer(decrypted_pek_hex)
77
+
78
+ async def fetch_and_decrypt(redis: AsyncRedis, options: RedenvOptions) -> Secrets:
79
+ """
80
+ Fetches all secrets for a given environment and decrypts them.
81
+ """
82
+ log("Expired Cache: Fetching secrets from source...", options.log, "high")
83
+
84
+ try:
85
+ pek = await get_pek(redis, options)
86
+ except Exception as e:
87
+ error(f"Failed to get PEK: {e}", options.log)
88
+ raise e
89
+
90
+ env_key = f"{options.environment}:{options.project}"
91
+ versioned_secrets = await redis.hgetall(env_key)
92
+
93
+ secrets = Secrets()
94
+ if not versioned_secrets:
95
+ log("No secrets found for this environment.", options.log)
96
+ return secrets
97
+
98
+ # versioned_secrets is Dict[str, str] where values are JSON strings of arrays
99
+
100
+ for key, history_str in versioned_secrets.items():
101
+ if key.startswith("__"):
102
+ continue
103
+
104
+ try:
105
+ history = json.loads(history_str) if isinstance(history_str, str) else history_str
106
+ if not isinstance(history, list) or len(history) == 0:
107
+ continue
108
+
109
+ # history[0] is the latest
110
+ encrypted_value = history[0]["value"]
111
+ decrypted_value = decrypt(encrypted_value, pek)
112
+ secrets[key] = decrypted_value
113
+ except Exception:
114
+ error(f'Failed to decrypt secret "{key}".', options.log)
115
+ continue
116
+
117
+ log(f"Successfully loaded {len(secrets)} secrets.", options.log)
118
+ return secrets
119
+
120
+ async def populate_env(secrets: Union[Dict[str, str], Secrets], options: RedenvOptions):
121
+ """
122
+ Injects secrets into the current runtime's environment.
123
+ """
124
+ log("Populating environment with secrets...", options.log)
125
+ injected_count = 0
126
+
127
+ for key, value in secrets.items():
128
+ if not options.env.override and key in os.environ:
129
+ continue
130
+
131
+ os.environ[key] = value
132
+ injected_count += 1
133
+
134
+ log(f"Injection complete. {injected_count} variables were set.", options.log)
135
+
136
+ async def set_secret(redis: AsyncRedis, options: RedenvOptions, key: str, value: str):
137
+ """
138
+ Sets a secret in Redis with versioning and history.
139
+ """
140
+ env_key = f"{options.environment}:{options.project}"
141
+ meta_key = f"meta@{options.project}"
142
+
143
+ # Fetch metadata (for PEK & historyLimit) and current history in parallel
144
+ metadata, current_history = await asyncio.gather(
145
+ redis.hgetall(meta_key),
146
+ redis.hget(env_key, key)
147
+ )
148
+
149
+ if not metadata:
150
+ raise RedenvError(f'Project "{options.project}" not found.', "PROJECT_NOT_FOUND")
151
+
152
+ # Reuse metadata to get PEK without extra fetch
153
+ pek = await get_pek(redis, options, metadata)
154
+
155
+ history_limit = int(metadata.get("historyLimit", 10))
156
+
157
+ # Fetch current history for the key
158
+ history = []
159
+ if current_history:
160
+ history = json.loads(current_history) if isinstance(current_history, str) else current_history
161
+
162
+ if not isinstance(history, list):
163
+ history = []
164
+
165
+ last_version = history[0]["version"] if len(history) > 0 else 0
166
+
167
+ # Encrypt new value
168
+ encrypted_value = encrypt(value, pek)
169
+
170
+ from datetime import datetime, timezone
171
+
172
+ new_version = {
173
+ "version": last_version + 1,
174
+ "value": encrypted_value,
175
+ "user": options.token_id, # Using token_id as the user/auditor
176
+ "createdAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
177
+ }
178
+
179
+ # Prepend new version
180
+ history.insert(0, new_version)
181
+
182
+ # Trim history
183
+ if history_limit > 0:
184
+ history = history[:history_limit]
185
+
186
+ # Write back
187
+ return await redis.hset(env_key, key, json.dumps(history))
188
+
189
+ async def get_secret_version(redis: AsyncRedis, options: RedenvOptions, cache: LRUCache, key: str, version: int, mode: Literal["id", "index"] = "id") -> Optional[str]:
190
+ """
191
+ Fetches a specific version of a secret with optimized caching and smart indexing.
192
+
193
+ Args:
194
+ mode: "id" (default) - positive numbers for version ID, negative for index from end.
195
+ "index" - treats version as array index (0=latest, 1=prev, -1=oldest).
196
+ """
197
+ hist_cache_key = f"history:{options.project}:{options.environment}:{key}"
198
+ entry = cache.get(hist_cache_key)
199
+
200
+ history_list = []
201
+
202
+ if entry:
203
+ log(f"History cache hit for {key}.", options.log)
204
+ history_list = entry.value
205
+ else:
206
+ log(f"History cache miss for {key}. Fetching full history...", options.log)
207
+ env_key = f"{options.environment}:{options.project}"
208
+ history_str = await redis.hget(env_key, key)
209
+
210
+ if history_str:
211
+ try:
212
+ raw_list = json.loads(history_str) if isinstance(history_str, str) else history_str
213
+ if isinstance(raw_list, list):
214
+ history_list = raw_list
215
+ cache[hist_cache_key] = CacheEntry(history_list, time.time())
216
+ except Exception as e:
217
+ error(f"Failed to parse history: {e}", options.log)
218
+
219
+ if not history_list:
220
+ return None
221
+
222
+ target_record = None
223
+
224
+ if mode == "index":
225
+ try:
226
+ # history_list is sorted newest-first
227
+ target_record = history_list[version]
228
+ except IndexError:
229
+ return None
230
+ else:
231
+ # Default "id" mode
232
+ if version < 0:
233
+ # Smart fallback: use as index for negative numbers
234
+ try:
235
+ target_record = history_list[version]
236
+ except IndexError:
237
+ return None
238
+ else:
239
+ # Standard search by ID
240
+ target_record = next((item for item in history_list if item.get("version") == version), None)
241
+
242
+ if not target_record:
243
+ return None
244
+
245
+ try:
246
+ pek = await get_pek(redis, options)
247
+ return decrypt(target_record["value"], pek)
248
+ except Exception as e:
249
+ error(f"Failed to decrypt version {version} ({mode}): {e}", options.log)
250
+ return None
251
+
@@ -0,0 +1,203 @@
1
+ Metadata-Version: 2.4
2
+ Name: redenv
3
+ Version: 0.2.0
4
+ Summary: A zero-knowledge, end-to-end encrypted secret management SDK for Python.
5
+ Project-URL: Homepage, https://github.com/redenv-labs/redenv
6
+ Project-URL: Documentation, https://github.com/redenv-labs/redenv/tree/main/packages/python-client
7
+ Project-URL: Repository, https://github.com/redenv-labs/redenv
8
+ Project-URL: Issues, https://github.com/redenv-labs/redenv/issues
9
+ Author-email: PRAS <prassamin@gmail.com>
10
+ License: MIT License
11
+
12
+ Copyright (c) 2026 PRAS
13
+
14
+ Permission is hereby granted, free of charge, to any person obtaining a copy
15
+ of this software and associated documentation files (the "Software"), to deal
16
+ in the Software without restriction, including without limitation the rights
17
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
18
+ copies of the Software, and to permit persons to whom the Software is
19
+ furnished to do so, subject to the following conditions:
20
+
21
+ The above copyright notice and this permission notice shall be included in all
22
+ copies or substantial portions of the Software.
23
+
24
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
25
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
26
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
27
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
28
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
29
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
30
+ SOFTWARE.
31
+ License-File: LICENSE
32
+ Keywords: dotenv,encryption,redis,sdk,secrets,security,upstash
33
+ Classifier: Development Status :: 4 - Beta
34
+ Classifier: Intended Audience :: Developers
35
+ Classifier: License :: OSI Approved :: MIT License
36
+ Classifier: Operating System :: OS Independent
37
+ Classifier: Programming Language :: Python :: 3
38
+ Classifier: Programming Language :: Python :: 3.8
39
+ Classifier: Programming Language :: Python :: 3.9
40
+ Classifier: Programming Language :: Python :: 3.10
41
+ Classifier: Programming Language :: Python :: 3.11
42
+ Classifier: Programming Language :: Python :: 3.12
43
+ Classifier: Topic :: Security
44
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
45
+ Requires-Python: >=3.8
46
+ Requires-Dist: cachetools>=5.0.0
47
+ Requires-Dist: cryptography>=41.0.0
48
+ Requires-Dist: upstash-redis>=1.0.0
49
+ Provides-Extra: dev
50
+ Requires-Dist: build; extra == 'dev'
51
+ Requires-Dist: pyright; extra == 'dev'
52
+ Requires-Dist: pytest; extra == 'dev'
53
+ Requires-Dist: python-dotenv; extra == 'dev'
54
+ Requires-Dist: ruff; extra == 'dev'
55
+ Requires-Dist: twine; extra == 'dev'
56
+ Description-Content-Type: text/markdown
57
+
58
+ # Redenv Python SDK
59
+
60
+ The official, zero-knowledge Python client for [Redenv](https://github.com/redenv-labs/redenv). Securely fetch, cache, and manage your environment variables at runtime.
61
+
62
+ ![PyPI - Version](https://img.shields.io/pypi/v/redenv)
63
+ ![PyPI - License](https://img.shields.io/pypi/l/redenv)
64
+ ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/redenv)
65
+
66
+ ## Features
67
+
68
+ - **🔒 Zero-Knowledge:** End-to-End Encryption. Secrets are decrypted locally using your Project Encryption Key (PEK).
69
+ - **⚡ High Performance:** In-memory `LRUCache` with `Stale-While-Revalidate` strategy for zero-latency reads.
70
+ - **🔄 Universal:** Native **Async** (`asyncio`) and **Synchronous** clients included.
71
+ - **🛠️ Developer Experience:**
72
+ - **Smart Casting:** `secrets.get("PORT", cast=int)`
73
+ - **Scoping:** `secrets.scope("STRIPE_")` for namespaced configs.
74
+ - **Validation:** `secrets.require("API_KEY")` fail-fast checks.
75
+ - **Time Travel:** Fetch historical versions of secrets.
76
+ - **🛡️ Secure by Default:** Secrets are masked (`********`) in logs to prevent accidental leaks.
77
+
78
+ ## Installation
79
+
80
+ ```bash
81
+ pip install redenv
82
+ ```
83
+
84
+ ## Quick Start
85
+
86
+ ### Async Client (FastAPI / Modern Apps)
87
+
88
+ ```python
89
+ import asyncio
90
+ import os
91
+ from redenv import Redenv
92
+
93
+ async def main():
94
+ client = Redenv({
95
+ "project": os.getenv("REDENV_PROJECT"),
96
+ "token_id": os.getenv("REDENV_TOKEN_ID"),
97
+ "token": os.getenv("REDENV_TOKEN_KEY"),
98
+ "upstash": {
99
+ "url": os.getenv("UPSTASH_REDIS_URL"),
100
+ "token": os.getenv("UPSTASH_REDIS_TOKEN")
101
+ }
102
+ })
103
+
104
+ # 1. Load Secrets (Populates os.environ by default)
105
+ secrets = await client.load()
106
+
107
+ # 2. Access Secrets
108
+ print(f"Database URL: {secrets['DATABASE_URL']}")
109
+
110
+ # 3. Smart Casting
111
+ port = secrets.get("PORT", cast=int)
112
+ debug = secrets.get("DEBUG", cast=bool)
113
+
114
+ if __name__ == "__main__":
115
+ asyncio.run(main())
116
+ ```
117
+
118
+ ### Synchronous Client (Flask / Scripts / Legacy)
119
+
120
+ Perfect for scripts or frameworks where `async/await` is not available at the top level.
121
+
122
+ ```python
123
+ from redenv import RedenvSync
124
+
125
+ client = RedenvSync({ ... }) # Same config as above
126
+
127
+ # Blocks until secrets are fetched
128
+ secrets = client.load()
129
+
130
+ print(secrets["API_KEY"])
131
+ ```
132
+
133
+ ## Advanced Usage
134
+
135
+ ### 1. Scoping & Validation
136
+ Organize large configurations and ensure critical keys exist.
137
+
138
+ ```python
139
+ secrets = await client.load()
140
+
141
+ # Fail if these keys are missing
142
+ secrets.require("STRIPE_KEY", "STRIPE_WEBHOOK")
143
+
144
+ # Create a subset of keys (e.g., keys starting with "STRIPE_")
145
+ # The prefix is automatically stripped.
146
+ stripe_config = secrets.scope("STRIPE_")
147
+
148
+ print(stripe_config["KEY"]) # Maps to STRIPE_KEY
149
+ print(stripe_config["WEBHOOK"]) # Maps to STRIPE_WEBHOOK
150
+ ```
151
+
152
+ ### 2. Time Travel (Version History)
153
+ Redenv stores a history of every secret change. You can access older versions for rollbacks or auditing.
154
+
155
+ ```python
156
+ # Get the absolute version 5
157
+ v5 = await client.get_version("API_KEY", 5)
158
+
159
+ # Get the previous version (1 version older than latest)
160
+ # Mode="index": 0=Latest, 1=Previous, -1=Oldest
161
+ prev = await client.get_version("API_KEY", 1, mode="index")
162
+
163
+ # Get the oldest version ever created
164
+ first = await client.get_version("API_KEY", -1)
165
+ ```
166
+
167
+ ### 3. Writing Secrets
168
+ You can update secrets programmatically. This automatically encrypts the value, increments the version, and updates the history.
169
+
170
+ ```python
171
+ await client.set("FEATURE_FLAG", "true")
172
+ ```
173
+
174
+ ### 4. Configuration Options
175
+
176
+ | Option | Type | Description | Default |
177
+ |:---|:---|:---|:---|
178
+ | `project` | str | Your Project ID | Required |
179
+ | `token_id` | str | Service Token Public ID | Required |
180
+ | `token` | str | Service Token Secret Key | Required |
181
+ | `upstash` | dict | `{ url: ..., token: ... }` | Required |
182
+ | `environment` | str | Target environment (dev, prod) | `development` |
183
+ | `log` | str | Log level (`none`, `low`, `high`) | `low` |
184
+ | `cache` | dict | `{ ttl: 300, swr: 86400 }` (seconds) | 5min / 24h |
185
+ | `env.override` | bool | Overwrite existing `os.environ` keys | `True` |
186
+
187
+ ```python
188
+ client = Redenv({
189
+ # ...
190
+ "env": {
191
+ "override": False # Protects local env vars from being overwritten
192
+ }
193
+ })
194
+ ```
195
+
196
+ ## Security
197
+
198
+ - **Masking:** If you accidentally print the `secrets` object, values are hidden: `Secrets({'API_KEY': '********'})`.
199
+ - **Zero-Knowledge:** The server (Upstash) never sees the plaintext. Decryption happens only in your application's memory.
200
+
201
+ ## License
202
+
203
+ MIT
@@ -0,0 +1,15 @@
1
+ redenv/__init__.py,sha256=FA93IXHIpI5yfm3NeytvuHnE0lcIwmymW7fer9jtV6U,211
2
+ redenv/client.py,sha256=zKy7YHt8su4u7mxAtFRIyYEBU-4x4SYqV1V91oRyb1s,5126
3
+ redenv/crypto.py,sha256=KtN7yv9qVPNdNQcWIQfiUakdLULulfs2oT39-oJq6wc,2196
4
+ redenv/errors.py,sha256=AMCC_ESUnqBfFHhJ_sGrvOzuR9MV-jwvpjQ-6KWQnuA,238
5
+ redenv/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ redenv/secrets.py,sha256=jVdjkJPMG4nqppzJ00eBTjyZ0V6FTWmvQ-aBQAnD6eM,3105
7
+ redenv/types.py,sha256=DlTZPTMzwQlqMEQTi2RzYsI4Qu8PZJem8HpjwmcXYyQ,1680
8
+ redenv/utils.py,sha256=ZJm6RIVjZqwe4_kogb8H3W3wY3y90QqSm18-ZuB5oPk,8632
9
+ redenv/sync/__init__.py,sha256=tP9hJvl09rcEWs6ZJWz-Jo7WI2wHGTdsLeMbs8DONgY,49
10
+ redenv/sync/client.py,sha256=d0H1m-k4XZYm9MKR1IIUBIpLd_C3rCb1o5gRshpEsxg,4950
11
+ redenv/sync/utils.py,sha256=9D2ei-4_OSWwbfzA3I_DqeMdLGX2DkUjo-NhCs0b3bU,6819
12
+ redenv-0.2.0.dist-info/METADATA,sha256=WlPSRF1Z8soaDALdr1ycozid47Mo2PdFeGglCpWOuqM,7247
13
+ redenv-0.2.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
14
+ redenv-0.2.0.dist-info/licenses/LICENSE,sha256=HFevb6E77SZAFe4_NUry6n_xpbs8FMzN8M8Tpvb753M,1061
15
+ redenv-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 PRAS
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.