redenv 0.2.0__tar.gz
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-0.2.0/.gitignore +9 -0
- redenv-0.2.0/CHANGELOG.md +27 -0
- redenv-0.2.0/LICENSE +21 -0
- redenv-0.2.0/Makefile +24 -0
- redenv-0.2.0/PKG-INFO +203 -0
- redenv-0.2.0/README.md +146 -0
- redenv-0.2.0/pyproject.toml +53 -0
- redenv-0.2.0/src/redenv/__init__.py +7 -0
- redenv-0.2.0/src/redenv/client.py +137 -0
- redenv-0.2.0/src/redenv/crypto.py +71 -0
- redenv-0.2.0/src/redenv/errors.py +6 -0
- redenv-0.2.0/src/redenv/py.typed +0 -0
- redenv-0.2.0/src/redenv/secrets.py +95 -0
- redenv-0.2.0/src/redenv/sync/__init__.py +3 -0
- redenv-0.2.0/src/redenv/sync/client.py +131 -0
- redenv-0.2.0/src/redenv/sync/utils.py +199 -0
- redenv-0.2.0/src/redenv/types.py +64 -0
- redenv-0.2.0/src/redenv/utils.py +251 -0
- redenv-0.2.0/tests/test_crypto.py +92 -0
redenv-0.2.0/.gitignore
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
## [0.2.0] - 2026-01-22
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Synchronous Client:** Added `RedenvSync` for blocking contexts (scripts, legacy apps).
|
|
10
|
+
- **Write Support:** Implemented `client.set(key, value)` with full version history management.
|
|
11
|
+
- **Smart Secrets Object:**
|
|
12
|
+
- `secrets.get(key, cast=int)`: Auto-convert types.
|
|
13
|
+
- `secrets.scope("PREFIX_")`: Create namespaced configuration subsets.
|
|
14
|
+
- `secrets.require("KEY")`: Fail-fast validation for missing keys.
|
|
15
|
+
- **Time Travel:** Added `client.get_version(key, v)` to fetch historical secrets. Supports both absolute IDs and relative indexing (0=Latest, 1=Previous).
|
|
16
|
+
- **Security Hardening:** `Secrets` object now masks values (`********`) in logs/print statements to prevent accidental leakage.
|
|
17
|
+
- **Override Protection:** Added `env.override` option to prevent overwriting existing environment variables.
|
|
18
|
+
|
|
19
|
+
## [0.1.0] - 2026-01-22
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- **Initial Release:** First public beta release of the `redenv` Python SDK.
|
|
24
|
+
- **Zero-Knowledge Security:** All cryptographic operations (AES-256-GCM, PBKDF2) are performed locally.
|
|
25
|
+
- **Async Support:** Built on `asyncio` and `upstash-redis` for high-performance non-blocking operations.
|
|
26
|
+
- **SWR Caching:** Implemented a robust `Stale-While-Revalidate` caching strategy using `cachetools.LRUCache`.
|
|
27
|
+
- **Environment Injection:** Automatically populates `os.environ` with decrypted secrets on `load()`.
|
redenv-0.2.0/LICENSE
ADDED
|
@@ -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.
|
redenv-0.2.0/Makefile
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
.PHONY: install test lint build clean example example-sync
|
|
2
|
+
|
|
3
|
+
# Install the package in editable mode with dev dependencies
|
|
4
|
+
install:
|
|
5
|
+
pip install -e ".[dev]"
|
|
6
|
+
|
|
7
|
+
# Run tests
|
|
8
|
+
test:
|
|
9
|
+
PYTHONPATH=src python -m pytest
|
|
10
|
+
|
|
11
|
+
# Run linting and type checking
|
|
12
|
+
lint:
|
|
13
|
+
python -m ruff check src tests
|
|
14
|
+
python -m pyright src
|
|
15
|
+
|
|
16
|
+
# Build the package artifacts
|
|
17
|
+
build:
|
|
18
|
+
rm -rf dist/ build/
|
|
19
|
+
python -m build
|
|
20
|
+
|
|
21
|
+
# Clean up build artifacts and caches
|
|
22
|
+
clean:
|
|
23
|
+
rm -rf dist/ build/ *.egg-info .pytest_cache .ruff_cache
|
|
24
|
+
find . -type d -name "__pycache__" -exec rm -rf {} +
|
redenv-0.2.0/PKG-INFO
ADDED
|
@@ -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
|
+

|
|
63
|
+

|
|
64
|
+

|
|
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
|
redenv-0.2.0/README.md
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# Redenv Python SDK
|
|
2
|
+
|
|
3
|
+
The official, zero-knowledge Python client for [Redenv](https://github.com/redenv-labs/redenv). Securely fetch, cache, and manage your environment variables at runtime.
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+

|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- **🔒 Zero-Knowledge:** End-to-End Encryption. Secrets are decrypted locally using your Project Encryption Key (PEK).
|
|
12
|
+
- **⚡ High Performance:** In-memory `LRUCache` with `Stale-While-Revalidate` strategy for zero-latency reads.
|
|
13
|
+
- **🔄 Universal:** Native **Async** (`asyncio`) and **Synchronous** clients included.
|
|
14
|
+
- **🛠️ Developer Experience:**
|
|
15
|
+
- **Smart Casting:** `secrets.get("PORT", cast=int)`
|
|
16
|
+
- **Scoping:** `secrets.scope("STRIPE_")` for namespaced configs.
|
|
17
|
+
- **Validation:** `secrets.require("API_KEY")` fail-fast checks.
|
|
18
|
+
- **Time Travel:** Fetch historical versions of secrets.
|
|
19
|
+
- **🛡️ Secure by Default:** Secrets are masked (`********`) in logs to prevent accidental leaks.
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install redenv
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Quick Start
|
|
28
|
+
|
|
29
|
+
### Async Client (FastAPI / Modern Apps)
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
import asyncio
|
|
33
|
+
import os
|
|
34
|
+
from redenv import Redenv
|
|
35
|
+
|
|
36
|
+
async def main():
|
|
37
|
+
client = Redenv({
|
|
38
|
+
"project": os.getenv("REDENV_PROJECT"),
|
|
39
|
+
"token_id": os.getenv("REDENV_TOKEN_ID"),
|
|
40
|
+
"token": os.getenv("REDENV_TOKEN_KEY"),
|
|
41
|
+
"upstash": {
|
|
42
|
+
"url": os.getenv("UPSTASH_REDIS_URL"),
|
|
43
|
+
"token": os.getenv("UPSTASH_REDIS_TOKEN")
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
# 1. Load Secrets (Populates os.environ by default)
|
|
48
|
+
secrets = await client.load()
|
|
49
|
+
|
|
50
|
+
# 2. Access Secrets
|
|
51
|
+
print(f"Database URL: {secrets['DATABASE_URL']}")
|
|
52
|
+
|
|
53
|
+
# 3. Smart Casting
|
|
54
|
+
port = secrets.get("PORT", cast=int)
|
|
55
|
+
debug = secrets.get("DEBUG", cast=bool)
|
|
56
|
+
|
|
57
|
+
if __name__ == "__main__":
|
|
58
|
+
asyncio.run(main())
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Synchronous Client (Flask / Scripts / Legacy)
|
|
62
|
+
|
|
63
|
+
Perfect for scripts or frameworks where `async/await` is not available at the top level.
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from redenv import RedenvSync
|
|
67
|
+
|
|
68
|
+
client = RedenvSync({ ... }) # Same config as above
|
|
69
|
+
|
|
70
|
+
# Blocks until secrets are fetched
|
|
71
|
+
secrets = client.load()
|
|
72
|
+
|
|
73
|
+
print(secrets["API_KEY"])
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Advanced Usage
|
|
77
|
+
|
|
78
|
+
### 1. Scoping & Validation
|
|
79
|
+
Organize large configurations and ensure critical keys exist.
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
secrets = await client.load()
|
|
83
|
+
|
|
84
|
+
# Fail if these keys are missing
|
|
85
|
+
secrets.require("STRIPE_KEY", "STRIPE_WEBHOOK")
|
|
86
|
+
|
|
87
|
+
# Create a subset of keys (e.g., keys starting with "STRIPE_")
|
|
88
|
+
# The prefix is automatically stripped.
|
|
89
|
+
stripe_config = secrets.scope("STRIPE_")
|
|
90
|
+
|
|
91
|
+
print(stripe_config["KEY"]) # Maps to STRIPE_KEY
|
|
92
|
+
print(stripe_config["WEBHOOK"]) # Maps to STRIPE_WEBHOOK
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### 2. Time Travel (Version History)
|
|
96
|
+
Redenv stores a history of every secret change. You can access older versions for rollbacks or auditing.
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
# Get the absolute version 5
|
|
100
|
+
v5 = await client.get_version("API_KEY", 5)
|
|
101
|
+
|
|
102
|
+
# Get the previous version (1 version older than latest)
|
|
103
|
+
# Mode="index": 0=Latest, 1=Previous, -1=Oldest
|
|
104
|
+
prev = await client.get_version("API_KEY", 1, mode="index")
|
|
105
|
+
|
|
106
|
+
# Get the oldest version ever created
|
|
107
|
+
first = await client.get_version("API_KEY", -1)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### 3. Writing Secrets
|
|
111
|
+
You can update secrets programmatically. This automatically encrypts the value, increments the version, and updates the history.
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
await client.set("FEATURE_FLAG", "true")
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### 4. Configuration Options
|
|
118
|
+
|
|
119
|
+
| Option | Type | Description | Default |
|
|
120
|
+
|:---|:---|:---|:---|
|
|
121
|
+
| `project` | str | Your Project ID | Required |
|
|
122
|
+
| `token_id` | str | Service Token Public ID | Required |
|
|
123
|
+
| `token` | str | Service Token Secret Key | Required |
|
|
124
|
+
| `upstash` | dict | `{ url: ..., token: ... }` | Required |
|
|
125
|
+
| `environment` | str | Target environment (dev, prod) | `development` |
|
|
126
|
+
| `log` | str | Log level (`none`, `low`, `high`) | `low` |
|
|
127
|
+
| `cache` | dict | `{ ttl: 300, swr: 86400 }` (seconds) | 5min / 24h |
|
|
128
|
+
| `env.override` | bool | Overwrite existing `os.environ` keys | `True` |
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
client = Redenv({
|
|
132
|
+
# ...
|
|
133
|
+
"env": {
|
|
134
|
+
"override": False # Protects local env vars from being overwritten
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Security
|
|
140
|
+
|
|
141
|
+
- **Masking:** If you accidentally print the `secrets` object, values are hidden: `Secrets({'API_KEY': '********'})`.
|
|
142
|
+
- **Zero-Knowledge:** The server (Upstash) never sees the plaintext. Decryption happens only in your application's memory.
|
|
143
|
+
|
|
144
|
+
## License
|
|
145
|
+
|
|
146
|
+
MIT
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "redenv"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "A zero-knowledge, end-to-end encrypted secret management SDK for Python."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { file = "LICENSE" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "PRAS", email = "prassamin@gmail.com" },
|
|
14
|
+
]
|
|
15
|
+
keywords = ["secrets", "security", "dotenv", "upstash", "redis", "encryption", "sdk"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.8",
|
|
22
|
+
"Programming Language :: Python :: 3.9",
|
|
23
|
+
"Programming Language :: Python :: 3.10",
|
|
24
|
+
"Programming Language :: Python :: 3.11",
|
|
25
|
+
"Programming Language :: Python :: 3.12",
|
|
26
|
+
"Topic :: Security",
|
|
27
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
28
|
+
"Operating System :: OS Independent",
|
|
29
|
+
]
|
|
30
|
+
dependencies = [
|
|
31
|
+
"upstash-redis>=1.0.0",
|
|
32
|
+
"cryptography>=41.0.0",
|
|
33
|
+
"cachetools>=5.0.0",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://github.com/redenv-labs/redenv"
|
|
38
|
+
Documentation = "https://github.com/redenv-labs/redenv/tree/main/packages/python-client"
|
|
39
|
+
Repository = "https://github.com/redenv-labs/redenv"
|
|
40
|
+
Issues = "https://github.com/redenv-labs/redenv/issues"
|
|
41
|
+
|
|
42
|
+
[project.optional-dependencies]
|
|
43
|
+
dev = [
|
|
44
|
+
"pytest",
|
|
45
|
+
"python-dotenv",
|
|
46
|
+
"build",
|
|
47
|
+
"twine",
|
|
48
|
+
"ruff",
|
|
49
|
+
"pyright"
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
[tool.hatch.build.targets.wheel]
|
|
53
|
+
packages = ["src/redenv"]
|
|
@@ -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)
|