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 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
+ ```
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,6 @@
1
+ httpx>=0.24.0
2
+
3
+ [dev]
4
+ pytest>=7.0
5
+ pytest-asyncio>=0.21
6
+ respx>=0.20
@@ -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"