ripv-py 1.0.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.
- ripv_py-1.0.0/PKG-INFO +125 -0
- ripv_py-1.0.0/README.md +112 -0
- ripv_py-1.0.0/pyproject.toml +15 -0
- ripv_py-1.0.0/setup.cfg +4 -0
- ripv_py-1.0.0/src/pv/__init__.py +48 -0
- ripv_py-1.0.0/src/pv/client.py +220 -0
- ripv_py-1.0.0/src/ripv_py.egg-info/PKG-INFO +125 -0
- ripv_py-1.0.0/src/ripv_py.egg-info/SOURCES.txt +10 -0
- ripv_py-1.0.0/src/ripv_py.egg-info/dependency_links.txt +1 -0
- ripv_py-1.0.0/src/ripv_py.egg-info/requires.txt +6 -0
- ripv_py-1.0.0/src/ripv_py.egg-info/top_level.txt +1 -0
- ripv_py-1.0.0/tests/test_client.py +254 -0
ripv_py-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ripv-py
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Python SDK for PersonalVault - secure API key retrieval
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.8
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: httpx>=0.24.0
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
11
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
|
|
12
|
+
Requires-Dist: respx>=0.20; extra == "dev"
|
|
13
|
+
|
|
14
|
+
# PersonalVault Python SDK
|
|
15
|
+
|
|
16
|
+
Secure API key retrieval for Python backend applications.
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install personalvault
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Quick Start
|
|
25
|
+
|
|
26
|
+
### Environment Variables
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
export PV_API_KEY="pvault_bk_..." # Required - your backend key
|
|
30
|
+
export PV_API_URL="https://pv.example.com" # Optional - defaults to http://localhost:8923
|
|
31
|
+
export PV_CACHE_TTL="3600" # Optional - cache TTL in seconds (default: 3600)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Synchronous (Django, Flask)
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from personalvault import init, pv
|
|
38
|
+
|
|
39
|
+
# Initialize once at startup
|
|
40
|
+
init()
|
|
41
|
+
|
|
42
|
+
# Retrieve keys instantly from cache
|
|
43
|
+
openai_key = pv("OPENAI_API_KEY")
|
|
44
|
+
db_password = pv("DB_PASS")
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Asynchronous (FastAPI, AIOHTTP)
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from personalvault import pv_async
|
|
51
|
+
|
|
52
|
+
# Auto-initializes on first call, then serves from cache
|
|
53
|
+
openai_key = await pv_async("OPENAI_API_KEY")
|
|
54
|
+
db_password = await pv_async("DB_PASS")
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## API Reference
|
|
58
|
+
|
|
59
|
+
### `init() -> None`
|
|
60
|
+
Synchronously fetch and cache all allowed keys. Blocks until complete.
|
|
61
|
+
|
|
62
|
+
### `pv(key_name: str) -> str`
|
|
63
|
+
Synchronous cache lookup. Raises `PVNotInitializedError` if `init()` hasn't been called or cache has expired.
|
|
64
|
+
|
|
65
|
+
### `pv_async(key_name: str) -> str`
|
|
66
|
+
Async key retrieval. Automatically initializes/refreshes the cache when needed.
|
|
67
|
+
|
|
68
|
+
## Exceptions
|
|
69
|
+
|
|
70
|
+
| Exception | When |
|
|
71
|
+
|---|---|
|
|
72
|
+
| `PVAuthError` | `PV_API_KEY` missing, invalid, or expired |
|
|
73
|
+
| `PVDeniedError` | Server rejected request (IP allowlist, etc.) |
|
|
74
|
+
| `PVKeyNotFoundError` | Key not in vault or not allowed for this backend key |
|
|
75
|
+
| `PVNotInitializedError` | `pv()` called before `init()` or cache expired |
|
|
76
|
+
|
|
77
|
+
## Framework Examples
|
|
78
|
+
|
|
79
|
+
### FastAPI
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from contextlib import asynccontextmanager
|
|
83
|
+
from fastapi import FastAPI
|
|
84
|
+
from personalvault import pv_async, pv
|
|
85
|
+
|
|
86
|
+
@asynccontextmanager
|
|
87
|
+
async def lifespan(app: FastAPI):
|
|
88
|
+
await pv_async("DB_URL") # warm the cache
|
|
89
|
+
yield
|
|
90
|
+
|
|
91
|
+
app = FastAPI(lifespan=lifespan)
|
|
92
|
+
|
|
93
|
+
@app.get("/")
|
|
94
|
+
async def root():
|
|
95
|
+
return {"db": pv("DB_URL")}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Flask
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
from flask import Flask
|
|
102
|
+
from personalvault import init, pv
|
|
103
|
+
|
|
104
|
+
app = Flask(__name__)
|
|
105
|
+
init() # fetch keys at import time
|
|
106
|
+
|
|
107
|
+
@app.route("/")
|
|
108
|
+
def index():
|
|
109
|
+
return {"db": pv("DB_URL")}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Django (settings.py)
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
from personalvault import init, pv
|
|
116
|
+
|
|
117
|
+
init()
|
|
118
|
+
|
|
119
|
+
DATABASES = {
|
|
120
|
+
"default": {
|
|
121
|
+
"ENGINE": "django.db.backends.postgresql",
|
|
122
|
+
"PASSWORD": pv("DB_PASS"),
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
```
|
ripv_py-1.0.0/README.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# PersonalVault Python SDK
|
|
2
|
+
|
|
3
|
+
Secure API key retrieval for Python backend applications.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install personalvault
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
### Environment Variables
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
export PV_API_KEY="pvault_bk_..." # Required - your backend key
|
|
17
|
+
export PV_API_URL="https://pv.example.com" # Optional - defaults to http://localhost:8923
|
|
18
|
+
export PV_CACHE_TTL="3600" # Optional - cache TTL in seconds (default: 3600)
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### Synchronous (Django, Flask)
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from personalvault import init, pv
|
|
25
|
+
|
|
26
|
+
# Initialize once at startup
|
|
27
|
+
init()
|
|
28
|
+
|
|
29
|
+
# Retrieve keys instantly from cache
|
|
30
|
+
openai_key = pv("OPENAI_API_KEY")
|
|
31
|
+
db_password = pv("DB_PASS")
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Asynchronous (FastAPI, AIOHTTP)
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from personalvault import pv_async
|
|
38
|
+
|
|
39
|
+
# Auto-initializes on first call, then serves from cache
|
|
40
|
+
openai_key = await pv_async("OPENAI_API_KEY")
|
|
41
|
+
db_password = await pv_async("DB_PASS")
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## API Reference
|
|
45
|
+
|
|
46
|
+
### `init() -> None`
|
|
47
|
+
Synchronously fetch and cache all allowed keys. Blocks until complete.
|
|
48
|
+
|
|
49
|
+
### `pv(key_name: str) -> str`
|
|
50
|
+
Synchronous cache lookup. Raises `PVNotInitializedError` if `init()` hasn't been called or cache has expired.
|
|
51
|
+
|
|
52
|
+
### `pv_async(key_name: str) -> str`
|
|
53
|
+
Async key retrieval. Automatically initializes/refreshes the cache when needed.
|
|
54
|
+
|
|
55
|
+
## Exceptions
|
|
56
|
+
|
|
57
|
+
| Exception | When |
|
|
58
|
+
|---|---|
|
|
59
|
+
| `PVAuthError` | `PV_API_KEY` missing, invalid, or expired |
|
|
60
|
+
| `PVDeniedError` | Server rejected request (IP allowlist, etc.) |
|
|
61
|
+
| `PVKeyNotFoundError` | Key not in vault or not allowed for this backend key |
|
|
62
|
+
| `PVNotInitializedError` | `pv()` called before `init()` or cache expired |
|
|
63
|
+
|
|
64
|
+
## Framework Examples
|
|
65
|
+
|
|
66
|
+
### FastAPI
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from contextlib import asynccontextmanager
|
|
70
|
+
from fastapi import FastAPI
|
|
71
|
+
from personalvault import pv_async, pv
|
|
72
|
+
|
|
73
|
+
@asynccontextmanager
|
|
74
|
+
async def lifespan(app: FastAPI):
|
|
75
|
+
await pv_async("DB_URL") # warm the cache
|
|
76
|
+
yield
|
|
77
|
+
|
|
78
|
+
app = FastAPI(lifespan=lifespan)
|
|
79
|
+
|
|
80
|
+
@app.get("/")
|
|
81
|
+
async def root():
|
|
82
|
+
return {"db": pv("DB_URL")}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Flask
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from flask import Flask
|
|
89
|
+
from personalvault import init, pv
|
|
90
|
+
|
|
91
|
+
app = Flask(__name__)
|
|
92
|
+
init() # fetch keys at import time
|
|
93
|
+
|
|
94
|
+
@app.route("/")
|
|
95
|
+
def index():
|
|
96
|
+
return {"db": pv("DB_URL")}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Django (settings.py)
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
from personalvault import init, pv
|
|
103
|
+
|
|
104
|
+
init()
|
|
105
|
+
|
|
106
|
+
DATABASES = {
|
|
107
|
+
"default": {
|
|
108
|
+
"ENGINE": "django.db.backends.postgresql",
|
|
109
|
+
"PASSWORD": pv("DB_PASS"),
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
```
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=64", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ripv-py"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Python SDK for PersonalVault - secure API key retrieval"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.8"
|
|
12
|
+
dependencies = ["httpx>=0.24.0"]
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
dev = ["pytest>=7.0", "pytest-asyncio>=0.21", "respx>=0.20"]
|
ripv_py-1.0.0/setup.cfg
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""PersonalVault Python SDK - secure API key retrieval for backend applications.
|
|
2
|
+
|
|
3
|
+
Usage (sync):
|
|
4
|
+
import pv
|
|
5
|
+
pv.init()
|
|
6
|
+
openai_key = pv("OPENAI_API_KEY")
|
|
7
|
+
|
|
8
|
+
Usage (async):
|
|
9
|
+
from pv import pv_async
|
|
10
|
+
openai_key = await pv_async("OPENAI_API_KEY")
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import types
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
from pv.client import (
|
|
17
|
+
pv,
|
|
18
|
+
pv_async,
|
|
19
|
+
init,
|
|
20
|
+
PVAuthError,
|
|
21
|
+
PVDeniedError,
|
|
22
|
+
PVKeyNotFoundError,
|
|
23
|
+
PVNotInitializedError,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"pv",
|
|
28
|
+
"pv_async",
|
|
29
|
+
"init",
|
|
30
|
+
"PVAuthError",
|
|
31
|
+
"PVDeniedError",
|
|
32
|
+
"PVKeyNotFoundError",
|
|
33
|
+
"PVNotInitializedError",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
__version__ = "1.0.0"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _CallableModule(types.ModuleType):
|
|
40
|
+
"""Makes ``import pv; pv("KEY")`` work by forwarding calls to ``pv.pv``."""
|
|
41
|
+
|
|
42
|
+
def __call__(self, key_name: str) -> str:
|
|
43
|
+
return pv(key_name)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Replace this module's class so the module itself is callable.
|
|
47
|
+
_self = sys.modules[__name__]
|
|
48
|
+
_self.__class__ = _CallableModule
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Core PersonalVault SDK client with sync and async key retrieval."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
from typing import Dict, List, Optional
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"pv",
|
|
13
|
+
"pv_async",
|
|
14
|
+
"init",
|
|
15
|
+
"PVAuthError",
|
|
16
|
+
"PVDeniedError",
|
|
17
|
+
"PVKeyNotFoundError",
|
|
18
|
+
"PVNotInitializedError",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
_SDK_VERSION = "1.0.0"
|
|
22
|
+
_USER_AGENT = f"pv-python-sdk/{_SDK_VERSION}"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# Exceptions
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
class PVAuthError(Exception):
|
|
30
|
+
"""Raised when the PV_API_KEY is missing, invalid, or expired."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class PVDeniedError(Exception):
|
|
34
|
+
"""Raised when the server rejects the request (403)."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class PVKeyNotFoundError(Exception):
|
|
38
|
+
"""Raised when a requested key is not present in the vault cache."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class PVNotInitializedError(Exception):
|
|
42
|
+
"""Raised when pv() is called before the SDK has been initialized."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# Internal state
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
_key_cache: Dict[str, str] = {}
|
|
50
|
+
_initialized: bool = False
|
|
51
|
+
_cache_expiry: float = 0.0
|
|
52
|
+
_cache_ttl: int = int(os.environ.get("PV_CACHE_TTL", "3600"))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _is_cache_valid() -> bool:
|
|
56
|
+
return _initialized and time.time() < _cache_expiry
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _get_api_key() -> str:
|
|
60
|
+
key = os.environ.get("PV_API_KEY")
|
|
61
|
+
if not key:
|
|
62
|
+
raise PVAuthError(
|
|
63
|
+
"PersonalVault: PV_API_KEY not found in environment.\n"
|
|
64
|
+
"Generate one at https://pv.sprkt.xyz/dashboard -> Backend Keys"
|
|
65
|
+
)
|
|
66
|
+
return key
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _get_api_url() -> str:
|
|
70
|
+
return os.environ.get("PV_API_URL", "https://pv.sprkt.xyz")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _build_headers(api_key: str) -> Dict[str, str]:
|
|
74
|
+
return {
|
|
75
|
+
"Content-Type": "application/json",
|
|
76
|
+
"Authorization": f"Bearer {api_key}",
|
|
77
|
+
"User-Agent": _USER_AGENT,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _handle_error_response(status_code: int, data: dict) -> None:
|
|
82
|
+
error_msg = data.get("error", "")
|
|
83
|
+
if status_code == 401:
|
|
84
|
+
raise PVAuthError(error_msg or "Invalid or expired backend key")
|
|
85
|
+
if status_code == 403:
|
|
86
|
+
raise PVDeniedError(error_msg or "Access denied")
|
|
87
|
+
raise Exception(error_msg or f"Request failed with status {status_code}")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _apply_fetched_keys(keys: Dict[str, str]) -> None:
|
|
91
|
+
global _key_cache, _initialized, _cache_expiry
|
|
92
|
+
_key_cache = keys
|
|
93
|
+
_initialized = True
|
|
94
|
+
_cache_expiry = time.time() + _cache_ttl
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
# Sync fetch (uses httpx.Client)
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
def _fetch_keys_sync(key_names: Optional[List[str]] = None) -> Dict[str, str]:
|
|
102
|
+
api_key = _get_api_key()
|
|
103
|
+
url = f"{_get_api_url()}/api/backend/keys"
|
|
104
|
+
body: dict = {}
|
|
105
|
+
if key_names:
|
|
106
|
+
body["keyNames"] = key_names
|
|
107
|
+
|
|
108
|
+
with httpx.Client() as client:
|
|
109
|
+
resp = client.post(url, json=body, headers=_build_headers(api_key))
|
|
110
|
+
|
|
111
|
+
if resp.status_code != 200:
|
|
112
|
+
try:
|
|
113
|
+
data = resp.json()
|
|
114
|
+
except Exception:
|
|
115
|
+
data = {}
|
|
116
|
+
_handle_error_response(resp.status_code, data)
|
|
117
|
+
|
|
118
|
+
return resp.json()["keys"]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
# Async fetch (uses httpx.AsyncClient)
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
async def _fetch_keys_async(key_names: Optional[List[str]] = None) -> Dict[str, str]:
|
|
126
|
+
api_key = _get_api_key()
|
|
127
|
+
url = f"{_get_api_url()}/api/backend/keys"
|
|
128
|
+
body: dict = {}
|
|
129
|
+
if key_names:
|
|
130
|
+
body["keyNames"] = key_names
|
|
131
|
+
|
|
132
|
+
async with httpx.AsyncClient() as client:
|
|
133
|
+
resp = await client.post(url, json=body, headers=_build_headers(api_key))
|
|
134
|
+
|
|
135
|
+
if resp.status_code != 200:
|
|
136
|
+
try:
|
|
137
|
+
data = resp.json()
|
|
138
|
+
except Exception:
|
|
139
|
+
data = {}
|
|
140
|
+
_handle_error_response(resp.status_code, data)
|
|
141
|
+
|
|
142
|
+
return resp.json()["keys"]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ---------------------------------------------------------------------------
|
|
146
|
+
# Public API
|
|
147
|
+
# ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
def init() -> None:
|
|
150
|
+
"""Synchronously initialize the SDK by fetching keys from PersonalVault.
|
|
151
|
+
|
|
152
|
+
Use this in synchronous frameworks (Django, Flask) that don't have an
|
|
153
|
+
event loop. Blocks until the fetch completes.
|
|
154
|
+
|
|
155
|
+
Raises:
|
|
156
|
+
PVAuthError: If PV_API_KEY is missing or invalid.
|
|
157
|
+
PVDeniedError: If the server rejects the request.
|
|
158
|
+
"""
|
|
159
|
+
if _is_cache_valid():
|
|
160
|
+
return
|
|
161
|
+
keys = _fetch_keys_sync()
|
|
162
|
+
_apply_fetched_keys(keys)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def pv(key_name: str) -> str:
|
|
166
|
+
"""Synchronously retrieve a key from the in-memory cache.
|
|
167
|
+
|
|
168
|
+
The SDK must be initialized first via ``init()`` or ``pv_async()``.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
key_name: The name of the key to retrieve.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
The key value as a string.
|
|
175
|
+
|
|
176
|
+
Raises:
|
|
177
|
+
PVNotInitializedError: If the SDK has not been initialized or the
|
|
178
|
+
cache has expired.
|
|
179
|
+
PVKeyNotFoundError: If the key is not in the cache.
|
|
180
|
+
"""
|
|
181
|
+
if not _is_cache_valid():
|
|
182
|
+
raise PVNotInitializedError(
|
|
183
|
+
"PersonalVault: not initialized. "
|
|
184
|
+
"Use pv_async() for the first call or call init()."
|
|
185
|
+
)
|
|
186
|
+
value = _key_cache.get(key_name)
|
|
187
|
+
if value is None:
|
|
188
|
+
raise PVKeyNotFoundError(
|
|
189
|
+
f"Key '{key_name}' not found in vault or not allowed for this backend key"
|
|
190
|
+
)
|
|
191
|
+
return value
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
async def pv_async(key_name: str) -> str:
|
|
195
|
+
"""Asynchronously retrieve a key, initializing the SDK if needed.
|
|
196
|
+
|
|
197
|
+
On the first call (or after cache expiry), this fetches keys from the
|
|
198
|
+
PersonalVault server. Subsequent calls return from the in-memory cache.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
key_name: The name of the key to retrieve.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
The key value as a string.
|
|
205
|
+
|
|
206
|
+
Raises:
|
|
207
|
+
PVAuthError: If PV_API_KEY is missing or invalid.
|
|
208
|
+
PVDeniedError: If the server rejects the request.
|
|
209
|
+
PVKeyNotFoundError: If the key is not in the fetched results.
|
|
210
|
+
"""
|
|
211
|
+
if not _is_cache_valid():
|
|
212
|
+
keys = await _fetch_keys_async()
|
|
213
|
+
_apply_fetched_keys(keys)
|
|
214
|
+
|
|
215
|
+
value = _key_cache.get(key_name)
|
|
216
|
+
if value is None:
|
|
217
|
+
raise PVKeyNotFoundError(
|
|
218
|
+
f"Key '{key_name}' not found in vault or not allowed for this backend key"
|
|
219
|
+
)
|
|
220
|
+
return value
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ripv-py
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Python SDK for PersonalVault - secure API key retrieval
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.8
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: httpx>=0.24.0
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
11
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
|
|
12
|
+
Requires-Dist: respx>=0.20; extra == "dev"
|
|
13
|
+
|
|
14
|
+
# PersonalVault Python SDK
|
|
15
|
+
|
|
16
|
+
Secure API key retrieval for Python backend applications.
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install personalvault
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Quick Start
|
|
25
|
+
|
|
26
|
+
### Environment Variables
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
export PV_API_KEY="pvault_bk_..." # Required - your backend key
|
|
30
|
+
export PV_API_URL="https://pv.example.com" # Optional - defaults to http://localhost:8923
|
|
31
|
+
export PV_CACHE_TTL="3600" # Optional - cache TTL in seconds (default: 3600)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Synchronous (Django, Flask)
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from personalvault import init, pv
|
|
38
|
+
|
|
39
|
+
# Initialize once at startup
|
|
40
|
+
init()
|
|
41
|
+
|
|
42
|
+
# Retrieve keys instantly from cache
|
|
43
|
+
openai_key = pv("OPENAI_API_KEY")
|
|
44
|
+
db_password = pv("DB_PASS")
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Asynchronous (FastAPI, AIOHTTP)
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from personalvault import pv_async
|
|
51
|
+
|
|
52
|
+
# Auto-initializes on first call, then serves from cache
|
|
53
|
+
openai_key = await pv_async("OPENAI_API_KEY")
|
|
54
|
+
db_password = await pv_async("DB_PASS")
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## API Reference
|
|
58
|
+
|
|
59
|
+
### `init() -> None`
|
|
60
|
+
Synchronously fetch and cache all allowed keys. Blocks until complete.
|
|
61
|
+
|
|
62
|
+
### `pv(key_name: str) -> str`
|
|
63
|
+
Synchronous cache lookup. Raises `PVNotInitializedError` if `init()` hasn't been called or cache has expired.
|
|
64
|
+
|
|
65
|
+
### `pv_async(key_name: str) -> str`
|
|
66
|
+
Async key retrieval. Automatically initializes/refreshes the cache when needed.
|
|
67
|
+
|
|
68
|
+
## Exceptions
|
|
69
|
+
|
|
70
|
+
| Exception | When |
|
|
71
|
+
|---|---|
|
|
72
|
+
| `PVAuthError` | `PV_API_KEY` missing, invalid, or expired |
|
|
73
|
+
| `PVDeniedError` | Server rejected request (IP allowlist, etc.) |
|
|
74
|
+
| `PVKeyNotFoundError` | Key not in vault or not allowed for this backend key |
|
|
75
|
+
| `PVNotInitializedError` | `pv()` called before `init()` or cache expired |
|
|
76
|
+
|
|
77
|
+
## Framework Examples
|
|
78
|
+
|
|
79
|
+
### FastAPI
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from contextlib import asynccontextmanager
|
|
83
|
+
from fastapi import FastAPI
|
|
84
|
+
from personalvault import pv_async, pv
|
|
85
|
+
|
|
86
|
+
@asynccontextmanager
|
|
87
|
+
async def lifespan(app: FastAPI):
|
|
88
|
+
await pv_async("DB_URL") # warm the cache
|
|
89
|
+
yield
|
|
90
|
+
|
|
91
|
+
app = FastAPI(lifespan=lifespan)
|
|
92
|
+
|
|
93
|
+
@app.get("/")
|
|
94
|
+
async def root():
|
|
95
|
+
return {"db": pv("DB_URL")}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Flask
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
from flask import Flask
|
|
102
|
+
from personalvault import init, pv
|
|
103
|
+
|
|
104
|
+
app = Flask(__name__)
|
|
105
|
+
init() # fetch keys at import time
|
|
106
|
+
|
|
107
|
+
@app.route("/")
|
|
108
|
+
def index():
|
|
109
|
+
return {"db": pv("DB_URL")}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Django (settings.py)
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
from personalvault import init, pv
|
|
116
|
+
|
|
117
|
+
init()
|
|
118
|
+
|
|
119
|
+
DATABASES = {
|
|
120
|
+
"default": {
|
|
121
|
+
"ENGINE": "django.db.backends.postgresql",
|
|
122
|
+
"PASSWORD": pv("DB_PASS"),
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
```
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/pv/__init__.py
|
|
4
|
+
src/pv/client.py
|
|
5
|
+
src/ripv_py.egg-info/PKG-INFO
|
|
6
|
+
src/ripv_py.egg-info/SOURCES.txt
|
|
7
|
+
src/ripv_py.egg-info/dependency_links.txt
|
|
8
|
+
src/ripv_py.egg-info/requires.txt
|
|
9
|
+
src/ripv_py.egg-info/top_level.txt
|
|
10
|
+
tests/test_client.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pv
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Tests for the PersonalVault Python SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from unittest.mock import patch
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import pytest
|
|
10
|
+
import pytest_asyncio # noqa: F401 (ensures plugin is loaded)
|
|
11
|
+
import respx
|
|
12
|
+
|
|
13
|
+
import pv
|
|
14
|
+
from pv import (
|
|
15
|
+
pv_async,
|
|
16
|
+
init,
|
|
17
|
+
PVAuthError,
|
|
18
|
+
PVDeniedError,
|
|
19
|
+
PVKeyNotFoundError,
|
|
20
|
+
PVNotInitializedError,
|
|
21
|
+
)
|
|
22
|
+
from pv import client as _client
|
|
23
|
+
|
|
24
|
+
API_URL = "http://localhost:8923"
|
|
25
|
+
FAKE_KEY = "pvault_bk_testkey123"
|
|
26
|
+
MOCK_KEYS = {"OPENAI_API_KEY": "sk-test123", "DB_PASS": "secret456"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.fixture(autouse=True)
|
|
30
|
+
def _reset_state(monkeypatch):
|
|
31
|
+
"""Reset SDK internal state before each test."""
|
|
32
|
+
_client._key_cache.clear()
|
|
33
|
+
_client._initialized = False
|
|
34
|
+
_client._cache_expiry = 0.0
|
|
35
|
+
_client._cache_ttl = 3600
|
|
36
|
+
monkeypatch.setenv("PV_API_URL", API_URL)
|
|
37
|
+
monkeypatch.delenv("PV_API_KEY", raising=False)
|
|
38
|
+
yield
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
# PV_API_KEY missing
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
class TestMissingApiKey:
|
|
46
|
+
def test_sync_init_raises(self):
|
|
47
|
+
with pytest.raises(PVAuthError, match="PV_API_KEY not found"):
|
|
48
|
+
init()
|
|
49
|
+
|
|
50
|
+
@pytest.mark.asyncio
|
|
51
|
+
async def test_async_raises(self):
|
|
52
|
+
with pytest.raises(PVAuthError, match="PV_API_KEY not found"):
|
|
53
|
+
await pv_async("ANY_KEY")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
# Sync pv() before init
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
class TestNotInitialized:
|
|
61
|
+
def test_pv_raises(self):
|
|
62
|
+
with pytest.raises(PVNotInitializedError, match="not initialized"):
|
|
63
|
+
pv("SOME_KEY")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
# Key not found
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
class TestKeyNotFound:
|
|
71
|
+
@respx.mock
|
|
72
|
+
def test_sync_key_not_found(self, monkeypatch):
|
|
73
|
+
monkeypatch.setenv("PV_API_KEY", FAKE_KEY)
|
|
74
|
+
respx.post(f"{API_URL}/api/backend/keys").mock(
|
|
75
|
+
return_value=httpx.Response(200, json={"keys": MOCK_KEYS})
|
|
76
|
+
)
|
|
77
|
+
init()
|
|
78
|
+
with pytest.raises(PVKeyNotFoundError, match="MISSING"):
|
|
79
|
+
pv("MISSING")
|
|
80
|
+
|
|
81
|
+
@respx.mock
|
|
82
|
+
@pytest.mark.asyncio
|
|
83
|
+
async def test_async_key_not_found(self, monkeypatch):
|
|
84
|
+
monkeypatch.setenv("PV_API_KEY", FAKE_KEY)
|
|
85
|
+
respx.post(f"{API_URL}/api/backend/keys").mock(
|
|
86
|
+
return_value=httpx.Response(200, json={"keys": MOCK_KEYS})
|
|
87
|
+
)
|
|
88
|
+
with pytest.raises(PVKeyNotFoundError, match="MISSING"):
|
|
89
|
+
await pv_async("MISSING")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
# Successful retrieval
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
class TestSuccessfulRetrieval:
|
|
97
|
+
@respx.mock
|
|
98
|
+
def test_sync_flow(self, monkeypatch):
|
|
99
|
+
"""import pv; pv.init(); pv("KEY") pattern."""
|
|
100
|
+
monkeypatch.setenv("PV_API_KEY", FAKE_KEY)
|
|
101
|
+
respx.post(f"{API_URL}/api/backend/keys").mock(
|
|
102
|
+
return_value=httpx.Response(200, json={"keys": MOCK_KEYS})
|
|
103
|
+
)
|
|
104
|
+
pv.init()
|
|
105
|
+
assert pv("OPENAI_API_KEY") == "sk-test123"
|
|
106
|
+
assert pv("DB_PASS") == "secret456"
|
|
107
|
+
|
|
108
|
+
@respx.mock
|
|
109
|
+
@pytest.mark.asyncio
|
|
110
|
+
async def test_async_flow(self, monkeypatch):
|
|
111
|
+
"""from pv import pv_async; await pv_async("KEY") pattern."""
|
|
112
|
+
monkeypatch.setenv("PV_API_KEY", FAKE_KEY)
|
|
113
|
+
respx.post(f"{API_URL}/api/backend/keys").mock(
|
|
114
|
+
return_value=httpx.Response(200, json={"keys": MOCK_KEYS})
|
|
115
|
+
)
|
|
116
|
+
assert await pv_async("OPENAI_API_KEY") == "sk-test123"
|
|
117
|
+
assert await pv_async("DB_PASS") == "secret456"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
# Cache expiry
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
class TestCacheExpiry:
|
|
125
|
+
@respx.mock
|
|
126
|
+
def test_expired_cache_raises_not_initialized(self, monkeypatch):
|
|
127
|
+
monkeypatch.setenv("PV_API_KEY", FAKE_KEY)
|
|
128
|
+
respx.post(f"{API_URL}/api/backend/keys").mock(
|
|
129
|
+
return_value=httpx.Response(200, json={"keys": MOCK_KEYS})
|
|
130
|
+
)
|
|
131
|
+
init()
|
|
132
|
+
assert pv("OPENAI_API_KEY") == "sk-test123"
|
|
133
|
+
|
|
134
|
+
# Force cache expiry
|
|
135
|
+
_client._cache_expiry = time.time() - 1
|
|
136
|
+
with pytest.raises(PVNotInitializedError):
|
|
137
|
+
pv("OPENAI_API_KEY")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ---------------------------------------------------------------------------
|
|
141
|
+
# HTTP error handling
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
class TestHttpErrors:
|
|
145
|
+
@respx.mock
|
|
146
|
+
def test_401_raises_auth_error(self, monkeypatch):
|
|
147
|
+
monkeypatch.setenv("PV_API_KEY", FAKE_KEY)
|
|
148
|
+
respx.post(f"{API_URL}/api/backend/keys").mock(
|
|
149
|
+
return_value=httpx.Response(
|
|
150
|
+
401, json={"error": "Invalid or expired backend key"}
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
with pytest.raises(PVAuthError, match="Invalid or expired"):
|
|
154
|
+
init()
|
|
155
|
+
|
|
156
|
+
@respx.mock
|
|
157
|
+
def test_403_raises_denied_error(self, monkeypatch):
|
|
158
|
+
monkeypatch.setenv("PV_API_KEY", FAKE_KEY)
|
|
159
|
+
respx.post(f"{API_URL}/api/backend/keys").mock(
|
|
160
|
+
return_value=httpx.Response(
|
|
161
|
+
403, json={"error": "IP not in allowlist"}
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
with pytest.raises(PVDeniedError, match="IP not in allowlist"):
|
|
165
|
+
init()
|
|
166
|
+
|
|
167
|
+
@respx.mock
|
|
168
|
+
@pytest.mark.asyncio
|
|
169
|
+
async def test_401_async(self, monkeypatch):
|
|
170
|
+
monkeypatch.setenv("PV_API_KEY", FAKE_KEY)
|
|
171
|
+
respx.post(f"{API_URL}/api/backend/keys").mock(
|
|
172
|
+
return_value=httpx.Response(
|
|
173
|
+
401, json={"error": "Invalid or expired backend key"}
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
with pytest.raises(PVAuthError):
|
|
177
|
+
await pv_async("ANY")
|
|
178
|
+
|
|
179
|
+
@respx.mock
|
|
180
|
+
@pytest.mark.asyncio
|
|
181
|
+
async def test_403_async(self, monkeypatch):
|
|
182
|
+
monkeypatch.setenv("PV_API_KEY", FAKE_KEY)
|
|
183
|
+
respx.post(f"{API_URL}/api/backend/keys").mock(
|
|
184
|
+
return_value=httpx.Response(403, json={"error": "Access denied"})
|
|
185
|
+
)
|
|
186
|
+
with pytest.raises(PVDeniedError):
|
|
187
|
+
await pv_async("ANY")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
# User-Agent header
|
|
192
|
+
# ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
class TestUserAgent:
|
|
195
|
+
@respx.mock
|
|
196
|
+
def test_sends_user_agent(self, monkeypatch):
|
|
197
|
+
monkeypatch.setenv("PV_API_KEY", FAKE_KEY)
|
|
198
|
+
route = respx.post(f"{API_URL}/api/backend/keys").mock(
|
|
199
|
+
return_value=httpx.Response(200, json={"keys": MOCK_KEYS})
|
|
200
|
+
)
|
|
201
|
+
init()
|
|
202
|
+
request = route.calls[0].request
|
|
203
|
+
assert request.headers["user-agent"] == "pv-python-sdk/1.0.0"
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ---------------------------------------------------------------------------
|
|
207
|
+
# Cache TTL env var
|
|
208
|
+
# ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
class TestCacheTTLConfig:
|
|
211
|
+
@respx.mock
|
|
212
|
+
def test_custom_ttl(self, monkeypatch):
|
|
213
|
+
monkeypatch.setenv("PV_API_KEY", FAKE_KEY)
|
|
214
|
+
_client._cache_ttl = 60 # 1 minute
|
|
215
|
+
respx.post(f"{API_URL}/api/backend/keys").mock(
|
|
216
|
+
return_value=httpx.Response(200, json={"keys": MOCK_KEYS})
|
|
217
|
+
)
|
|
218
|
+
init()
|
|
219
|
+
# Verify expiry is roughly 60s from now, not 3600s
|
|
220
|
+
assert _client._cache_expiry < time.time() + 120
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# ---------------------------------------------------------------------------
|
|
224
|
+
# Init is idempotent when cache is valid
|
|
225
|
+
# ---------------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
class TestIdempotentInit:
|
|
228
|
+
@respx.mock
|
|
229
|
+
def test_init_only_fetches_once(self, monkeypatch):
|
|
230
|
+
monkeypatch.setenv("PV_API_KEY", FAKE_KEY)
|
|
231
|
+
route = respx.post(f"{API_URL}/api/backend/keys").mock(
|
|
232
|
+
return_value=httpx.Response(200, json={"keys": MOCK_KEYS})
|
|
233
|
+
)
|
|
234
|
+
init()
|
|
235
|
+
init()
|
|
236
|
+
assert route.call_count == 1
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# ---------------------------------------------------------------------------
|
|
240
|
+
# Module is callable (import pv; pv("KEY"))
|
|
241
|
+
# ---------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
class TestCallableModule:
|
|
244
|
+
@respx.mock
|
|
245
|
+
def test_module_callable(self, monkeypatch):
|
|
246
|
+
"""Verify `import pv; pv.init(); pv('KEY')` works."""
|
|
247
|
+
monkeypatch.setenv("PV_API_KEY", FAKE_KEY)
|
|
248
|
+
respx.post(f"{API_URL}/api/backend/keys").mock(
|
|
249
|
+
return_value=httpx.Response(200, json={"keys": MOCK_KEYS})
|
|
250
|
+
)
|
|
251
|
+
pv.init()
|
|
252
|
+
# pv is the module, calling it should work
|
|
253
|
+
assert pv("OPENAI_API_KEY") == "sk-test123"
|
|
254
|
+
assert pv("DB_PASS") == "secret456"
|