ripv-py 1.0.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.
pv/__init__.py 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
pv/client.py ADDED
@@ -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,6 @@
1
+ pv/__init__.py,sha256=XvuXioP7vIF7FOXBqj4OloPhKfgri_IMTUuprwzkcD4,976
2
+ pv/client.py,sha256=vbmrJdvyxAq5mFTfL9M7EdQtrYb2tz0ot1EQ9OCaxOY,6676
3
+ ripv_py-1.0.0.dist-info/METADATA,sha256=NELmvI0azMZIFObytSEGZK41hmO71y3fvWotUb8siL4,2909
4
+ ripv_py-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
5
+ ripv_py-1.0.0.dist-info/top_level.txt,sha256=EfDDilaSYq7eE6JeB7uEi0pH_wB4I-t4tmKu5rqnjf4,3
6
+ ripv_py-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ pv