ranbval-sdk 1.4.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.
Files changed (38) hide show
  1. ranbval_sdk/__init__.py +115 -0
  2. ranbval_sdk/_internal/__init__.py +5 -0
  3. ranbval_sdk/_internal/defaults.py +11 -0
  4. ranbval_sdk/_internal/logging.py +17 -0
  5. ranbval_sdk/_internal/transport.py +17 -0
  6. ranbval_sdk/config/__init__.py +45 -0
  7. ranbval_sdk/config/access.py +367 -0
  8. ranbval_sdk/config/declarative.py +72 -0
  9. ranbval_sdk/config/loader.py +312 -0
  10. ranbval_sdk/config/manifest.py +61 -0
  11. ranbval_sdk/crypto/__init__.py +27 -0
  12. ranbval_sdk/crypto/audit.py +77 -0
  13. ranbval_sdk/crypto/cipher.py +284 -0
  14. ranbval_sdk/crypto/repo_policy.py +22 -0
  15. ranbval_sdk/crypto/secret_string.py +262 -0
  16. ranbval_sdk/exceptions.py +109 -0
  17. ranbval_sdk/integrations/__init__.py +1 -0
  18. ranbval_sdk/integrations/factory.py +58 -0
  19. ranbval_sdk/integrations/proxy.py +204 -0
  20. ranbval_sdk/integrations/universal.py +135 -0
  21. ranbval_sdk/policy/__init__.py +20 -0
  22. ranbval_sdk/policy/repo.py +159 -0
  23. ranbval_sdk/py.typed +0 -0
  24. ranbval_sdk/serializers/__init__.py +32 -0
  25. ranbval_sdk/serializers/audit.py +20 -0
  26. ranbval_sdk/serializers/proxy.py +37 -0
  27. ranbval_sdk/serializers/telemetry.py +77 -0
  28. ranbval_sdk/serializers/token.py +18 -0
  29. ranbval_sdk/telemetry/__init__.py +22 -0
  30. ranbval_sdk/telemetry/client.py +135 -0
  31. ranbval_sdk/telemetry/context.py +100 -0
  32. ranbval_sdk/telemetry/decorators.py +97 -0
  33. ranbval_sdk/telemetry/sampling.py +104 -0
  34. ranbval_sdk/telemetry/settings.py +35 -0
  35. ranbval_sdk-1.4.0.dist-info/METADATA +675 -0
  36. ranbval_sdk-1.4.0.dist-info/RECORD +38 -0
  37. ranbval_sdk-1.4.0.dist-info/WHEEL +4 -0
  38. ranbval_sdk-1.4.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,115 @@
1
+ """Ranbval SDK — keep API secrets out of plaintext config.
2
+
3
+ Encrypted vault tokens live in your ``.ranbval`` file; the SDK decrypts them only at the
4
+ moment of use and never lets the plaintext reach a ``print``, log, or ``repr``.
5
+
6
+ Public API, grouped by concern (each name re-exported from its home subpackage):
7
+
8
+ - **Config** (:mod:`ranbval_sdk.config`) — ``load_ranbval``, ``get_project_key``,
9
+ ``find_ranbval_file``, ``find_ranbval_directory``, ``resolve_ranbval_mode``.
10
+ - **Access** (:mod:`ranbval_sdk.config.access`) — ``Vault``, ``env``, ``inject``,
11
+ ``secrets``, ``iter_secrets``, ``public``, ``public_config``, ``is_public``,
12
+ ``Secret``, ``SecretConfig``, ``SecretProvider``.
13
+ - **Crypto** (:mod:`ranbval_sdk.crypto`) — ``safe_decrypt``, ``decrypt_key``,
14
+ ``SecretString``, ``get_audit_log``, ``clear_audit_log``, ``audit_scope``.
15
+ - **Telemetry** (:mod:`ranbval_sdk.telemetry`) — ``emit_telemetry``, ``aemit_telemetry``,
16
+ ``track``, ``tracked``.
17
+ - **Integrations** (:mod:`ranbval_sdk.integrations`) — ``secure_client``, ``build_secure_client``.
18
+ - **Secure proxy** (:mod:`ranbval_sdk.integrations.proxy`) — ``proxy_request``, ``aproxy_request``.
19
+ - **Exceptions** (:mod:`ranbval_sdk.exceptions`) — ``RanbvalError`` and its subclasses.
20
+
21
+ Basic use::
22
+
23
+ from ranbval_sdk import load_ranbval, decrypt_key
24
+ load_ranbval()
25
+ client = openai.OpenAI(api_key=decrypt_key("OPENAI_API_KEY").use())
26
+ """
27
+
28
+ from ranbval_sdk.config import (
29
+ Secret,
30
+ SecretConfig,
31
+ SecretProvider,
32
+ Vault,
33
+ env,
34
+ find_ranbval_directory,
35
+ find_ranbval_file,
36
+ get_project_key,
37
+ inject,
38
+ is_public,
39
+ iter_secrets,
40
+ load_ranbval,
41
+ public,
42
+ public_config,
43
+ resolve_ranbval_mode,
44
+ secrets,
45
+ )
46
+ from ranbval_sdk.crypto import (
47
+ SecretString,
48
+ audit_scope,
49
+ clear_audit_log,
50
+ decrypt_key,
51
+ get_audit_log,
52
+ safe_decrypt,
53
+ )
54
+ from ranbval_sdk.exceptions import (
55
+ MissingKeyError,
56
+ ProxyError,
57
+ RanbvalConfigError,
58
+ RanbvalDecryptError,
59
+ RanbvalError,
60
+ RepoNotAllowedError,
61
+ RepoPolicyError,
62
+ )
63
+ from ranbval_sdk.integrations.factory import secure_client
64
+ from ranbval_sdk.integrations.proxy import aproxy_request, proxy_request
65
+ from ranbval_sdk.integrations.universal import build_secure_client
66
+ from ranbval_sdk.telemetry import aemit_telemetry, emit_telemetry, track, tracked
67
+
68
+ __version__ = "1.4.0"
69
+
70
+ __all__ = [
71
+ # Config
72
+ "load_ranbval",
73
+ "get_project_key",
74
+ "find_ranbval_file",
75
+ "find_ranbval_directory",
76
+ "resolve_ranbval_mode",
77
+ # Access
78
+ "Vault",
79
+ "env",
80
+ "inject",
81
+ "secrets",
82
+ "iter_secrets",
83
+ "public",
84
+ "public_config",
85
+ "is_public",
86
+ "Secret",
87
+ "SecretConfig",
88
+ "SecretProvider",
89
+ # Crypto
90
+ "safe_decrypt",
91
+ "decrypt_key",
92
+ "SecretString",
93
+ "get_audit_log",
94
+ "clear_audit_log",
95
+ "audit_scope",
96
+ # Telemetry
97
+ "emit_telemetry",
98
+ "aemit_telemetry",
99
+ "track",
100
+ "tracked",
101
+ # Integrations
102
+ "secure_client",
103
+ "build_secure_client",
104
+ # Secure proxy
105
+ "proxy_request",
106
+ "aproxy_request",
107
+ # Exceptions
108
+ "RanbvalError",
109
+ "RanbvalDecryptError",
110
+ "RanbvalConfigError",
111
+ "MissingKeyError",
112
+ "RepoNotAllowedError",
113
+ "RepoPolicyError",
114
+ "ProxyError",
115
+ ]
@@ -0,0 +1,5 @@
1
+ """Internal cross-cutting utilities (not part of the public API).
2
+
3
+ - :mod:`~ranbval_sdk._internal.defaults` — shared constants and the telemetry-debug hook.
4
+ - :mod:`~ranbval_sdk._internal.transport` — HTTPS via urllib with certifi's CA bundle.
5
+ """
@@ -0,0 +1,11 @@
1
+ """Shared default constants for optional configuration.
2
+
3
+ Override with environment variables when needed (e.g. self-hosted or local dev).
4
+ """
5
+
6
+ # Password-manager origin only — no ``/api`` suffix (SDK appends ``/api/...`` paths).
7
+ DEFAULT_RANBVAL_HOST = "https://api.ranbval.com"
8
+
9
+ # The stderr debug warning moved to ``_internal.logging``; re-exported here so any
10
+ # existing ``from ranbval_sdk._internal.defaults import warn_telemetry_send_failed`` keeps working.
11
+ from ranbval_sdk._internal.logging import warn_telemetry_send_failed # noqa: E402,F401
@@ -0,0 +1,17 @@
1
+ """Internal diagnostics — opt-in stderr warnings for otherwise-silent failures.
2
+
3
+ The SDK never prints by default. Setting ``RANBVAL_TELEMETRY_DEBUG=1`` surfaces why a
4
+ best-effort telemetry POST failed, so CI can debug it without changing code.
5
+ """
6
+
7
+ import os
8
+ import sys
9
+
10
+
11
+ def warn_telemetry_send_failed(host: str, exc: BaseException) -> None:
12
+ """If ``RANBVAL_TELEMETRY_DEBUG=1``, print why POST /api/telemetry failed (default is silent)."""
13
+ v = (os.environ.get("RANBVAL_TELEMETRY_DEBUG") or "").strip().lower()
14
+ if v not in ("1", "true", "yes", "on"):
15
+ return
16
+ url = f"{host.rstrip('/')}/api/telemetry"
17
+ print(f"[Ranbval] Telemetry POST failed ({url}): {exc!r}", file=sys.stderr)
@@ -0,0 +1,17 @@
1
+ """HTTPS via urllib with certifi's CA bundle (fixes common macOS SSL verify failures)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ssl
6
+ import urllib.request
7
+ from typing import Any
8
+
9
+
10
+ def urlopen(req: urllib.request.Request, timeout: float | None = None) -> Any:
11
+ full = req.get_full_url()
12
+ if full.lower().startswith("https:"):
13
+ import certifi
14
+
15
+ ctx = ssl.create_default_context(cafile=certifi.where())
16
+ return urllib.request.urlopen(req, timeout=timeout, context=ctx)
17
+ return urllib.request.urlopen(req, timeout=timeout)
@@ -0,0 +1,45 @@
1
+ """Configuration: loading ``.ranbval`` files and accessing the values in them.
2
+
3
+ - :mod:`~ranbval_sdk.config.loader` — parse and merge layered ``.ranbval*`` files into the env.
4
+ - :mod:`~ranbval_sdk.config.access` — imperative access (``Vault``, ``inject``, ``secrets``).
5
+ - :mod:`~ranbval_sdk.config.declarative` — class-based access (``Secret``, ``SecretConfig``).
6
+ """
7
+
8
+ from ranbval_sdk.config.access import (
9
+ SecretProvider,
10
+ Vault,
11
+ env,
12
+ inject,
13
+ is_public,
14
+ iter_secrets,
15
+ public,
16
+ public_config,
17
+ secrets,
18
+ )
19
+ from ranbval_sdk.config.declarative import Secret, SecretConfig
20
+ from ranbval_sdk.config.loader import (
21
+ find_ranbval_directory,
22
+ find_ranbval_file,
23
+ get_project_key,
24
+ load_ranbval,
25
+ resolve_ranbval_mode,
26
+ )
27
+
28
+ __all__ = [
29
+ "load_ranbval",
30
+ "get_project_key",
31
+ "find_ranbval_file",
32
+ "find_ranbval_directory",
33
+ "resolve_ranbval_mode",
34
+ "Vault",
35
+ "env",
36
+ "inject",
37
+ "secrets",
38
+ "iter_secrets",
39
+ "public",
40
+ "public_config",
41
+ "is_public",
42
+ "Secret",
43
+ "SecretConfig",
44
+ "SecretProvider",
45
+ ]
@@ -0,0 +1,367 @@
1
+ """High-level, ergonomic access to your ``.ranbval`` configuration surface.
2
+
3
+ Thin, readable wrappers over :func:`~ranbval_sdk.config.loader.load_ranbval` and
4
+ :func:`~ranbval_sdk.crypto.decrypt_key` so applications consume secrets with almost no
5
+ boilerplate — a :class:`Vault` mapping, an ``@inject`` decorator, a ``secrets()`` context
6
+ manager, and a :class:`Secret` descriptor. Decryption still happens only in
7
+ ``crypto.safe_decrypt``; these are pure convenience. Secrets stay sealed by default.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import contextlib
13
+ import functools
14
+ import inspect
15
+ import os
16
+ import threading
17
+ from collections.abc import Callable, Iterator
18
+ from dataclasses import dataclass
19
+ from typing import Any, Protocol, runtime_checkable
20
+
21
+ from ranbval_sdk.config.loader import load_ranbval
22
+ from ranbval_sdk.crypto.secret_string import SecretString
23
+ from ranbval_sdk.exceptions import MissingKeyError, RanbvalConfigError
24
+
25
+ _loaded_modes: set[str] = set()
26
+ _load_lock = threading.Lock()
27
+
28
+ #: Sentinel so ``public(name)`` can tell "no default given" apart from ``default=None``.
29
+ _UNSET = object()
30
+
31
+
32
+ def _is_token(value: str | None) -> bool:
33
+ """A value is a Ranbval vault token when it carries the ``ranbval.`` prefix."""
34
+ return bool(value) and value.startswith("ranbval.")
35
+
36
+
37
+ def _ensure_env_loaded(mode: str | None = None) -> None:
38
+ """Load ``.ranbval*`` files for ``mode`` exactly once per process (thread-safe)."""
39
+ marker = mode or "__default__"
40
+ if marker in _loaded_modes:
41
+ return
42
+ with _load_lock:
43
+ if marker not in _loaded_modes:
44
+ load_ranbval(mode=mode)
45
+ _loaded_modes.add(marker)
46
+
47
+
48
+ def _resolve(env_var: str, *, reveal: bool) -> Any:
49
+ """Decrypt a vault token or pass a plain value through; optionally reveal plaintext."""
50
+ raw = os.environ.get(env_var)
51
+ if raw is None:
52
+ raise MissingKeyError(
53
+ f"{env_var!r} is not set — did you create it in your .ranbval file?"
54
+ )
55
+ if not _is_token(raw):
56
+ return raw # ordinary, safe-to-commit config value
57
+ from ranbval_sdk.crypto import decrypt_key
58
+
59
+ secret = decrypt_key(env_var)
60
+ return secret.use() if reveal else secret
61
+
62
+
63
+ @runtime_checkable
64
+ class SecretProvider(Protocol):
65
+ """Anything that can hand back a plaintext secret by name."""
66
+
67
+ def reveal(self, name: str) -> str: ...
68
+
69
+
70
+ @dataclass(frozen=True, slots=True)
71
+ class _Options:
72
+ mode: str | None = None
73
+ override: bool = False
74
+ autoload: bool = True
75
+
76
+
77
+ class Vault:
78
+ """A lazy, dunder-rich view over your configuration surface.
79
+
80
+ Loads ``.ranbval*`` on first access, then serves values by attribute or item —
81
+ plain config as ``str``, secrets as :class:`SecretString` (decrypted once, cached)::
82
+
83
+ vault = Vault()
84
+ vault.OPENAI_API_KEY # -> SecretString
85
+ vault["DATABASE_URL"] # -> str (plain value)
86
+ vault.reveal("OPENAI_API_KEY") # -> plaintext str, one line
87
+ "STRIPE_KEY" in vault # membership test
88
+ """
89
+
90
+ __slots__ = ("_cache", "_loaded", "_opts", "_lock")
91
+
92
+ def __init__(
93
+ self, *, mode: str | None = None, override: bool = False, autoload: bool = True
94
+ ):
95
+ self._cache: dict[str, Any] = {}
96
+ self._loaded = False
97
+ self._lock = threading.Lock()
98
+ self._opts = _Options(mode=mode, override=override, autoload=autoload)
99
+
100
+ def _ensure_loaded(self) -> None:
101
+ if self._opts.autoload and not self._loaded:
102
+ with self._lock:
103
+ if not self._loaded:
104
+ load_ranbval(mode=self._opts.mode, override=self._opts.override)
105
+ self._loaded = True
106
+
107
+ def _get(self, name: str) -> Any:
108
+ self._ensure_loaded()
109
+ if name not in self._cache:
110
+ self._cache[name] = _resolve(name, reveal=False)
111
+ return self._cache[name]
112
+
113
+ # -- mapping protocol ---------------------------------------------------
114
+ def __getattr__(self, name: str) -> Any:
115
+ if name.startswith("_"):
116
+ raise AttributeError(name)
117
+ try:
118
+ return self._get(name)
119
+ except KeyError as exc:
120
+ raise AttributeError(str(exc)) from exc
121
+
122
+ def __getitem__(self, name: str) -> Any:
123
+ return self._get(name)
124
+
125
+ def __contains__(self, name: str) -> bool:
126
+ self._ensure_loaded()
127
+ return name in os.environ
128
+
129
+ def __iter__(self) -> Iterator[str]:
130
+ self._ensure_loaded()
131
+ return iter(os.environ)
132
+
133
+ # -- public (unencrypted) access ---------------------------------------
134
+ def public(self, name: str, default: Any = _UNSET) -> str:
135
+ """Return a **plaintext** config value the public way — same policy as :func:`public`.
136
+
137
+ A key declared ``[secrets]`` (or any ``ranbval.*`` token) is refused, so a secret can
138
+ never be read through this public path::
139
+
140
+ env.public("DATABASE_URL") # -> plain str
141
+ env.public("OPENAI_API_KEY") # -> RanbvalConfigError (it's a secret)
142
+ """
143
+ self._ensure_loaded()
144
+ return _lookup_public(name, default)
145
+
146
+ def public_config(self) -> dict[str, str]:
147
+ """Every ``[public]``-declared key as ``{name: plaintext}`` (secrets never included)."""
148
+ self._ensure_loaded()
149
+ return _lookup_public_config()
150
+
151
+ # -- ergonomic helpers --------------------------------------------------
152
+ def reveal(self, name: str) -> str:
153
+ """Return the plaintext value in a single call (decrypts if needed)."""
154
+ value = self._get(name)
155
+ return value.use() if isinstance(value, SecretString) else value
156
+
157
+ async def areveal(self, name: str) -> str:
158
+ """Async, non-blocking :meth:`reveal` — decrypt + policy fetch run off-loop.
159
+
160
+ The vault-token decrypt makes a network call for repo policy; on an event
161
+ loop use this so FastAPI / asyncio callers don't block::
162
+
163
+ key = await vault.areveal("OPENAI_API_KEY")
164
+ """
165
+ import asyncio
166
+
167
+ return await asyncio.to_thread(self.reveal, name)
168
+
169
+ def get(self, name: str, default: Any = None) -> Any:
170
+ try:
171
+ return self._get(name)
172
+ except KeyError:
173
+ return default
174
+
175
+ def wipe(self) -> None:
176
+ """Zero every cached secret from memory."""
177
+ for value in self._cache.values():
178
+ if isinstance(value, SecretString):
179
+ value.wipe()
180
+ self._cache.clear()
181
+
182
+ def __repr__(self) -> str: # never prints values — names only
183
+ return f"<Vault loaded={self._loaded} cached={list(self._cache)}>"
184
+
185
+
186
+ #: Ready-to-use module singleton, e.g. ``ranbval_sdk.env.reveal("OPENAI_API_KEY")``.
187
+ env = Vault()
188
+
189
+
190
+ def inject(
191
+ *names: str, reveal: bool = False, mode: str | None = None, **aliases: str
192
+ ) -> Callable:
193
+ """Decorator that injects decrypted secrets into a function as keyword arguments.
194
+
195
+ ::
196
+
197
+ @inject("OPENAI_API_KEY") # -> kwarg `openai_api_key` (SecretString)
198
+ def main(openai_api_key):
199
+ client = OpenAI(api_key=openai_api_key.use()) # reveal only at the call site
200
+
201
+ **Security-first default:** injects a sealed :class:`SecretString` — it refuses to be
202
+ printed or logged, and wipes itself from memory. Plaintext is never handed out by
203
+ default; call ``.use()`` at the exact point you pass it to an API. ``reveal=True`` is
204
+ an explicit opt-in that injects a guarded ``_ProtectedStr`` (still refuses to print) —
205
+ use it only when a library needs a plain ``str`` and you accept the trade-off.
206
+
207
+ Works on sync **and** async functions. Any argument the caller passes explicitly is
208
+ left untouched.
209
+ """
210
+ mapping: dict[str, str] = {name.lower(): name for name in names}
211
+ mapping.update(aliases)
212
+
213
+ def _fill(kwargs: dict[str, Any]) -> None:
214
+ _ensure_env_loaded(mode)
215
+ for param, env_var in mapping.items():
216
+ if param not in kwargs:
217
+ kwargs[param] = _resolve(env_var, reveal=reveal)
218
+
219
+ def decorator(fn: Callable) -> Callable:
220
+ if inspect.iscoroutinefunction(fn):
221
+
222
+ @functools.wraps(fn)
223
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
224
+ _fill(kwargs)
225
+ return await fn(*args, **kwargs)
226
+
227
+ return async_wrapper
228
+
229
+ @functools.wraps(fn)
230
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
231
+ _fill(kwargs)
232
+ return fn(*args, **kwargs)
233
+
234
+ return wrapper
235
+
236
+ return decorator
237
+
238
+
239
+ @contextlib.contextmanager
240
+ def secrets(*, mode: str | None = None, override: bool = False) -> Iterator[Vault]:
241
+ """Context manager: load config, yield a :class:`Vault`, wipe secrets on exit.
242
+
243
+ ::
244
+
245
+ with secrets() as vault:
246
+ client = OpenAI(api_key=vault.reveal("OPENAI_API_KEY"))
247
+ # every decrypted secret is zeroed here
248
+ """
249
+ vault = Vault(mode=mode, override=override, autoload=True)
250
+ vault._ensure_loaded()
251
+ try:
252
+ yield vault
253
+ finally:
254
+ vault.wipe()
255
+
256
+
257
+ def iter_secrets(*, mode: str | None = None) -> Iterator[tuple[str, SecretString]]:
258
+ """Yield ``(name, SecretString)`` for every vault token in the environment.
259
+
260
+ ::
261
+
262
+ for name, secret in iter_secrets():
263
+ print(name) # names only — values stay sealed
264
+ """
265
+ _ensure_env_loaded(mode)
266
+ from ranbval_sdk.crypto import decrypt_key
267
+
268
+ for name, raw in os.environ.items():
269
+ if _is_token(raw):
270
+ yield name, decrypt_key(name)
271
+
272
+
273
+ # -- public (unencrypted) configuration --------------------------------------
274
+ def _lookup_public(name: str, default: Any) -> str:
275
+ """The single place that enforces the public-access policy (env assumed loaded).
276
+
277
+ A key declared ``[secrets]`` — or any ``ranbval.*`` token value — is **never** returned
278
+ here; both raise. This is what guarantees a secret can never leak through a public path,
279
+ no matter which surface (function or ``Vault`` method) the caller used.
280
+ """
281
+ from ranbval_sdk.config import manifest
282
+
283
+ if manifest.is_secret(name):
284
+ raise RanbvalConfigError(
285
+ f"{name!r} is declared under [secrets]; use decrypt_key({name!r}) instead. "
286
+ "public() only returns plaintext configuration.",
287
+ code="not_a_public_key",
288
+ )
289
+
290
+ raw = os.environ.get(name)
291
+ if raw is None:
292
+ if default is not _UNSET:
293
+ return default
294
+ raise MissingKeyError(
295
+ f"{name!r} is not set — did you create it in your .ranbval file?"
296
+ )
297
+ if _is_token(raw):
298
+ raise RanbvalConfigError(
299
+ f"{name!r} holds an encrypted vault token, not a plaintext value. "
300
+ f"Use decrypt_key({name!r}) to decrypt it.",
301
+ code="not_a_public_key",
302
+ )
303
+ return raw
304
+
305
+
306
+ def _lookup_public_config() -> dict[str, str]:
307
+ """Collect all ``[public]``-declared plaintext values (env assumed loaded)."""
308
+ from ranbval_sdk.config import manifest
309
+
310
+ out: dict[str, str] = {}
311
+ for name in manifest.public_names():
312
+ raw = os.environ.get(name)
313
+ if raw is not None and not _is_token(raw):
314
+ out[name] = raw
315
+ return out
316
+
317
+
318
+ def public(name: str, default: Any = _UNSET, *, mode: str | None = None) -> str:
319
+ """Return a **plaintext** config value — never decrypts, never a :class:`SecretString`.
320
+
321
+ For values you intentionally keep unencrypted (``DATABASE_URL``, ``CORS_ORIGINS``,
322
+ ``PORT``, …). Declare them under a ``[public]`` section in ``.ranbval`` to make the
323
+ intent explicit::
324
+
325
+ # .ranbval
326
+ [public]
327
+ DATABASE_URL=postgresql://localhost/mydb
328
+ CORS_ORIGINS=https://a.com,https://b.com
329
+
330
+ # app.py
331
+ from ranbval_sdk import public
332
+ db = public("DATABASE_URL") # -> plain str
333
+
334
+ Safety rails:
335
+
336
+ - If the key was declared under ``[secrets]``, this raises — use ``decrypt_key`` for it.
337
+ - If the value looks like an encrypted ``ranbval.*`` token, this raises rather than
338
+ handing back ciphertext (you almost certainly meant :func:`~ranbval_sdk.decrypt_key`).
339
+
340
+ ``default`` is returned when the key is absent (otherwise :class:`MissingKeyError`).
341
+ """
342
+ _ensure_env_loaded(mode)
343
+ return _lookup_public(name, default)
344
+
345
+
346
+ def public_config(*, mode: str | None = None) -> dict[str, str]:
347
+ """Return every key declared under ``[public]`` as a ``{name: plaintext}`` dict.
348
+
349
+ ::
350
+
351
+ cfg = public_config()
352
+ app.add_middleware(CORSMiddleware, allow_origins=cfg["CORS_ORIGINS"].split(","))
353
+ """
354
+ _ensure_env_loaded(mode)
355
+ return _lookup_public_config()
356
+
357
+
358
+ def is_public(name: str) -> bool:
359
+ """True when *name* was declared under a ``[public]`` section in ``.ranbval``."""
360
+ from ranbval_sdk.config import manifest
361
+
362
+ return manifest.is_public(name)
363
+
364
+
365
+ # The declarative, class-based API (``Secret`` / ``SecretConfig``) lives in
366
+ # ``ranbval_sdk.config.declarative`` — a distinct access style from the imperative
367
+ # ``Vault`` / ``inject`` / ``secrets`` above. Both are re-exported from ``config``.
@@ -0,0 +1,72 @@
1
+ """Declarative secret configuration — the class-based access style.
2
+
3
+ Complements the imperative API in :mod:`ranbval_sdk.config.access` (``Vault`` / ``inject`` /
4
+ ``secrets``): here you *declare* a config class whose fields are :class:`Secret` descriptors,
5
+ decrypted lazily and cached per subclass. Both styles resolve through the same
6
+ ``config.access._resolve`` so behavior and sealing defaults stay identical.
7
+
8
+ ::
9
+
10
+ class Config(SecretConfig):
11
+ openai = Secret("OPENAI_API_KEY")
12
+ stripe = Secret("STRIPE_KEY", reveal=True) # plaintext str
13
+
14
+ Config.openai # -> SecretString (cached on the class)
15
+ Config.stripe # -> plaintext str
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import Any
21
+
22
+ from ranbval_sdk.config.access import _ensure_env_loaded, _resolve
23
+ from ranbval_sdk.crypto.secret_string import SecretString
24
+
25
+
26
+ class Secret:
27
+ """Descriptor for declaring a secret on a config class — decrypted lazily, cached.
28
+
29
+ ::
30
+
31
+ class Config(SecretConfig):
32
+ openai = Secret("OPENAI_API_KEY")
33
+ stripe = Secret("STRIPE_KEY", reveal=True) # plaintext str
34
+
35
+ Config.openai # -> SecretString (cached on the class)
36
+ Config.stripe # -> plaintext str
37
+ """
38
+
39
+ __slots__ = ("env_var", "reveal", "attr")
40
+
41
+ def __init__(self, env_var: str, *, reveal: bool = False):
42
+ self.env_var = env_var
43
+ self.reveal = reveal
44
+ self.attr = env_var
45
+
46
+ def __set_name__(self, owner: type, name: str) -> None:
47
+ self.attr = name
48
+
49
+ def __get__(self, obj: Any, owner: type | None = None) -> Any:
50
+ holder = owner if owner is not None else type(obj)
51
+ _ensure_env_loaded()
52
+ store = holder._secret_cache
53
+ if self.env_var not in store:
54
+ store[self.env_var] = _resolve(self.env_var, reveal=False)
55
+ value = store[self.env_var]
56
+ if self.reveal and isinstance(value, SecretString):
57
+ return value.use()
58
+ return value
59
+
60
+
61
+ class SecretConfig:
62
+ """Base for declarative secret config classes; each subclass gets its own cache."""
63
+
64
+ _secret_cache: dict[str, Any] = {}
65
+ _secret_fields: tuple[str, ...] = ()
66
+
67
+ def __init_subclass__(cls, **kwargs: Any) -> None:
68
+ super().__init_subclass__(**kwargs)
69
+ cls._secret_cache = {}
70
+ cls._secret_fields = tuple(
71
+ name for name, value in vars(cls).items() if isinstance(value, Secret)
72
+ )