secrefs 0.2.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.
secrefs/__init__.py ADDED
@@ -0,0 +1,110 @@
1
+ import os
2
+ from typing import Dict, List, Optional
3
+
4
+ from .control_plane_client import (
5
+ ControlPlaneClient,
6
+ ControlPlaneCredentialSource,
7
+ ControlPlaneRequestError,
8
+ MintCredentialResponse,
9
+ MintedAWSCredentials,
10
+ MintedBitwardenCredentials,
11
+ )
12
+ from .parser import (
13
+ ParsedSecretRef,
14
+ SecRefParseError,
15
+ is_secret_ref,
16
+ parse_secret_ref,
17
+ try_parse_secret_ref,
18
+ )
19
+ from .providers.aws import AWSSecretsManagerProvider
20
+ from .providers.base import ProviderHealth, SecretFetchError, SecretFetchRequest, SecretProvider
21
+ from .providers.bitwarden import BitwardenProvider
22
+ from .providers.local import LocalProvider
23
+ from .providers.vault import VaultProvider
24
+ from .resolver import (
25
+ CheckResult,
26
+ ProviderRegistry,
27
+ ResolutionFailure,
28
+ SecRefsResolutionError,
29
+ check_references,
30
+ expand_environ,
31
+ expand_key_value_map,
32
+ )
33
+ from .ttl_cache import TtlCache
34
+
35
+
36
+ def create_default_providers() -> Dict[str, SecretProvider]:
37
+ """Builds the default provider registry: aws, vault, local, bitwarden."""
38
+ return {
39
+ "aws": AWSSecretsManagerProvider(),
40
+ "vault": VaultProvider(),
41
+ "local": LocalProvider(),
42
+ "bitwarden": BitwardenProvider(),
43
+ }
44
+
45
+
46
+ class SecRefs:
47
+ """Mirrors the Node SDK's `secRefs.init()` / `expandEnv()` / `expandString()` API."""
48
+
49
+ def __init__(
50
+ self,
51
+ providers: Optional[Dict[str, SecretProvider]] = None,
52
+ strict: bool = True,
53
+ ) -> None:
54
+ self.providers: Dict[str, SecretProvider] = providers or create_default_providers()
55
+ self.strict = strict
56
+
57
+ async def init(self) -> List[str]:
58
+ """Expands sec:// values found in os.environ, mutating it in place."""
59
+ return await expand_environ(self.providers, strict=self.strict)
60
+
61
+ async def expand_env(self, env: Dict[str, Optional[str]]) -> Dict[str, str]:
62
+ """Expands sec:// values in an arbitrary key/value map without touching os.environ."""
63
+ return await expand_key_value_map(env, self.providers, strict=self.strict)
64
+
65
+ async def expand_string(self, value: str) -> str:
66
+ """Expands a single string if it's a sec:// reference; otherwise returns it unchanged."""
67
+ if not is_secret_ref(value):
68
+ return value
69
+ result = await expand_key_value_map({"__value__": value}, self.providers, strict=True)
70
+ return result["__value__"]
71
+
72
+ async def check(self, env: Optional[Dict[str, Optional[str]]] = None) -> List[CheckResult]:
73
+ """Dry-run validation of every sec:// reference in `env` (defaults to os.environ)."""
74
+ return await check_references(env if env is not None else dict(os.environ), self.providers)
75
+
76
+
77
+ sec_refs = SecRefs()
78
+
79
+ __all__ = [
80
+ "SecRefs",
81
+ "sec_refs",
82
+ "create_default_providers",
83
+ "ParsedSecretRef",
84
+ "SecRefParseError",
85
+ "is_secret_ref",
86
+ "parse_secret_ref",
87
+ "try_parse_secret_ref",
88
+ "AWSSecretsManagerProvider",
89
+ "VaultProvider",
90
+ "LocalProvider",
91
+ "BitwardenProvider",
92
+ "SecretProvider",
93
+ "SecretFetchRequest",
94
+ "SecretFetchError",
95
+ "ProviderHealth",
96
+ "TtlCache",
97
+ "ControlPlaneClient",
98
+ "ControlPlaneCredentialSource",
99
+ "ControlPlaneRequestError",
100
+ "MintCredentialResponse",
101
+ "MintedAWSCredentials",
102
+ "MintedBitwardenCredentials",
103
+ "SecRefsResolutionError",
104
+ "ResolutionFailure",
105
+ "CheckResult",
106
+ "ProviderRegistry",
107
+ "expand_environ",
108
+ "expand_key_value_map",
109
+ "check_references",
110
+ ]
secrefs/cli.py ADDED
@@ -0,0 +1,140 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import asyncio
5
+ import os
6
+ import signal
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import List
11
+
12
+ from . import SecRefs, SecRefsResolutionError
13
+ from .envfile import parse_env_file_text
14
+
15
+
16
+ def _load_dotenv(path: Path) -> None:
17
+ """Loads a `.env` file into `os.environ`, correctly preserving
18
+ `sec://...#field` fragments (see `envfile.py`). Mirrors dotenv.config()'s
19
+ default precedence: never overrides a value already present in
20
+ `os.environ` (e.g. set explicitly by the shell/CI)."""
21
+ if not path.exists():
22
+ return
23
+ parsed = parse_env_file_text(path.read_text(encoding="utf-8"))
24
+ for key, value in parsed.items():
25
+ os.environ.setdefault(key, value)
26
+
27
+
28
+ def _build_parser() -> argparse.ArgumentParser:
29
+ parser = argparse.ArgumentParser(
30
+ prog="secrefs-py",
31
+ description="BYOV secret reference engine - expand sec:// references in memory at runtime",
32
+ )
33
+ subparsers = parser.add_subparsers(dest="subcommand", required=True)
34
+
35
+ run_parser = subparsers.add_parser(
36
+ "run", help="Resolve sec:// references and spawn a child process"
37
+ )
38
+ run_parser.add_argument("--env-file", default=".env", help="path to a .env file to load first")
39
+ run_parser.add_argument(
40
+ "--no-env-file", action="store_true", help="skip loading a .env file"
41
+ )
42
+ run_parser.add_argument(
43
+ "command", nargs=argparse.REMAINDER, help="command to run, e.g. -- python app.py"
44
+ )
45
+
46
+ check_parser = subparsers.add_parser(
47
+ "check", help="Validate sec:// references and provider reachability"
48
+ )
49
+ check_parser.add_argument("--env-file", default=".env", help="path to a .env file to load first")
50
+ check_parser.add_argument(
51
+ "--no-env-file", action="store_true", help="skip loading a .env file"
52
+ )
53
+
54
+ return parser
55
+
56
+
57
+ async def _run(args: argparse.Namespace) -> int:
58
+ command: List[str] = [c for c in args.command if c != "--"]
59
+ if not command:
60
+ print(
61
+ "secrefs-py run: no command given. Usage: secrefs-py run -- <command> [args...]",
62
+ file=sys.stderr,
63
+ )
64
+ return 1
65
+
66
+ if not args.no_env_file:
67
+ _load_dotenv(Path(args.env_file))
68
+
69
+ instance = SecRefs()
70
+ try:
71
+ changed = await instance.init()
72
+ if changed:
73
+ print(
74
+ f"secrefs-py: resolved {len(changed)} secret reference(s): {', '.join(changed)}",
75
+ file=sys.stderr,
76
+ )
77
+ except SecRefsResolutionError as exc:
78
+ print("secrefs-py: failed to resolve one or more secret references:", file=sys.stderr)
79
+ print(str(exc), file=sys.stderr)
80
+ return 1
81
+
82
+ process = subprocess.Popen(command, env=os.environ.copy())
83
+
84
+ forwarded_signals = [
85
+ s for s in (signal.SIGINT, signal.SIGTERM, getattr(signal, "SIGHUP", None)) if s is not None
86
+ ]
87
+
88
+ def _forward(sig: int, _frame: object) -> None:
89
+ if process.poll() is None:
90
+ process.send_signal(sig)
91
+
92
+ original_handlers = {sig: signal.getsignal(sig) for sig in forwarded_signals}
93
+ for sig in forwarded_signals:
94
+ signal.signal(sig, _forward)
95
+
96
+ try:
97
+ return process.wait()
98
+ finally:
99
+ for sig, handler in original_handlers.items():
100
+ signal.signal(sig, handler)
101
+
102
+
103
+ async def _check(args: argparse.Namespace) -> int:
104
+ if not args.no_env_file:
105
+ _load_dotenv(Path(args.env_file))
106
+
107
+ instance = SecRefs()
108
+ results = await instance.check()
109
+
110
+ if not results:
111
+ print("secrefs-py check: no sec:// references found in the environment.")
112
+ return 0
113
+
114
+ ok_count = 0
115
+ for result in results:
116
+ icon = "✓" if result.ok else "✗"
117
+ print(f"{icon} {result.key} -> {result.ref}")
118
+ if result.ok:
119
+ ok_count += 1
120
+ else:
121
+ print(f" {result.message}")
122
+
123
+ print(f"\n{ok_count}/{len(results)} reference(s) resolved successfully.")
124
+ return 0 if ok_count == len(results) else 1
125
+
126
+
127
+ def main() -> None:
128
+ parser = _build_parser()
129
+ args = parser.parse_args()
130
+
131
+ if args.subcommand == "run":
132
+ exit_code = asyncio.run(_run(args))
133
+ else:
134
+ exit_code = asyncio.run(_check(args))
135
+
136
+ sys.exit(exit_code)
137
+
138
+
139
+ if __name__ == "__main__":
140
+ main()
@@ -0,0 +1,206 @@
1
+ """
2
+ Thin HTTP client for a running control plane's credential-broker endpoint
3
+ (docs/control-plane-design.md §7). This is the piece §10 flagged as the
4
+ missing link: every provider that supports control-plane-sourced credentials
5
+ (AWSSecretsManagerProvider, BitwardenProvider - see their `control_plane`
6
+ constructor option) constructs one of these instead of only ever reading
7
+ ambient env vars.
8
+
9
+ Deliberately just an HTTP wrapper with no retry/backoff/circuit-breaking
10
+ logic - a mint failure surfaces as a normal raised exception, same as any
11
+ other provider fetch failure, and the caller's existing error handling
12
+ (resolver.py's asyncio.gather aggregation) already does the right thing
13
+ with it.
14
+
15
+ The default transport is `urllib` from the standard library, offloaded to a
16
+ thread with `asyncio.to_thread` exactly the way boto3 and hvac calls are.
17
+ One POST to one endpoint doesn't justify making every SecRefs install carry
18
+ an HTTP client dependency it otherwise has no use for; a caller who wants
19
+ their own (httpx, aiohttp, a test double) passes `transport`.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import asyncio
25
+ import json
26
+ import urllib.error
27
+ import urllib.request
28
+ from dataclasses import dataclass
29
+ from typing import Any, Awaitable, Callable, Dict, Optional, Tuple, Union
30
+
31
+ MINT_ENDPOINT = "/v1/credentials/mint"
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class MintedAWSCredentials:
36
+ access_key_id: str
37
+ secret_access_key: str
38
+ session_token: str
39
+ expiration: str # ISO-8601 timestamp
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class MintedBitwardenCredentials:
44
+ access_token: str
45
+ # Explicitly not a TTL promise - see
46
+ # apps/control-plane/src/providers/bitwarden.ts.
47
+ note: str = ""
48
+ organization_id: Optional[str] = None
49
+
50
+
51
+ MintedCredentials = Union[MintedAWSCredentials, MintedBitwardenCredentials]
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class MintCredentialResponse:
56
+ provider: str
57
+ # None when the control plane names a provider this SDK doesn't know how
58
+ # to parse credentials for. Left to the calling provider to reject, so
59
+ # the error it raises is the same "returned a X credential, expected Y"
60
+ # one it raises for a known-but-wrong provider rather than a second,
61
+ # differently-worded failure mode.
62
+ credentials: Optional[MintedCredentials] = None
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class ControlPlaneCredentialSource:
67
+ """What a provider's `control_plane` constructor option needs - shared
68
+ shape between AWSSecretsManagerProvider and BitwardenProvider (and any
69
+ future control-plane-aware provider)."""
70
+
71
+ base_url: str
72
+ """Base URL of a running control plane, e.g. from $SECREFS_CONTROL_PLANE_URL."""
73
+ token: str
74
+ """Bootstrap token or a verified OIDC token, e.g. from $SECREFS_CONTROL_PLANE_TOKEN."""
75
+ alias: str
76
+ """Which VaultConnection alias this provider instance represents - this
77
+ is what the control plane's RBAC grants are scoped against, not the
78
+ `sec://` alias this provider happens to be registered under (though in
79
+ practice they're usually the same string)."""
80
+ client: Optional["ControlPlaneClient"] = None
81
+ """Injected for testing - defaults to a real ControlPlaneClient."""
82
+
83
+
84
+ # (url, headers, body) -> (status, body). A non-2xx status is a *response*,
85
+ # not an exception: the body carries the control plane's denial reason and
86
+ # the caller needs it verbatim.
87
+ ControlPlaneTransport = Callable[[str, Dict[str, str], bytes], Awaitable[Tuple[int, bytes]]]
88
+
89
+
90
+ async def _urllib_transport(url: str, headers: Dict[str, str], body: bytes) -> Tuple[int, bytes]:
91
+ def send() -> Tuple[int, bytes]:
92
+ request = urllib.request.Request(url, data=body, headers=headers, method="POST")
93
+ try:
94
+ with urllib.request.urlopen(request) as response: # noqa: S310 - caller-supplied https URL
95
+ return int(response.status), bytes(response.read())
96
+ except urllib.error.HTTPError as exc:
97
+ # urllib raises on 4xx/5xx; a denial is a well-formed answer
98
+ # here, so hand the status and body back rather than the error.
99
+ return int(exc.code), bytes(exc.read())
100
+
101
+ return await asyncio.to_thread(send)
102
+
103
+
104
+ class ControlPlaneRequestError(Exception):
105
+ """Raised for a well-formed error response from the control plane (401,
106
+ 403, 502, ...) - `status` and the message come straight from its
107
+ `{"error": ...}` body, so a denial reason (e.g. 'no grant authorizes
108
+ path...') reaches the caller verbatim rather than as an opaque HTTP
109
+ failure."""
110
+
111
+ def __init__(self, status: int, message: str) -> None:
112
+ self.status = status
113
+ super().__init__(message)
114
+
115
+
116
+ class ControlPlaneClient:
117
+ def __init__(
118
+ self,
119
+ base_url: str,
120
+ token: str,
121
+ transport: Optional[ControlPlaneTransport] = None,
122
+ ) -> None:
123
+ self._base_url = base_url.rstrip("/")
124
+ self._token = token
125
+ self._transport: ControlPlaneTransport = transport or _urllib_transport
126
+
127
+ async def mint_credential(self, alias: str, path: str) -> MintCredentialResponse:
128
+ """Authenticates, authorizes, and resolves a credential for
129
+ `alias`/`path` - see the control plane's POST /v1/credentials/mint.
130
+ Raises ControlPlaneRequestError for any non-2xx response."""
131
+ url = f"{self._base_url}{MINT_ENDPOINT}"
132
+ headers = {
133
+ "content-type": "application/json",
134
+ "authorization": f"Bearer {self._token}",
135
+ }
136
+ body = json.dumps({"alias": alias, "path": path}).encode("utf-8")
137
+
138
+ try:
139
+ status, raw = await self._transport(url, headers, body)
140
+ except Exception as exc: # noqa: BLE001 - re-raised with the URL for context
141
+ raise ValueError(f"could not reach control plane at {self._base_url}: {exc}") from exc
142
+
143
+ if not 200 <= status < 300:
144
+ raise ControlPlaneRequestError(
145
+ status,
146
+ _error_message(raw)
147
+ or f'control plane returned {status} for alias "{alias}" path "{path}"',
148
+ )
149
+
150
+ try:
151
+ payload = json.loads(raw)
152
+ except json.JSONDecodeError as exc:
153
+ raise ValueError(
154
+ f"control plane returned a non-JSON success response for alias "
155
+ f'"{alias}" path "{path}": {exc}'
156
+ ) from exc
157
+
158
+ return _parse_mint_response(payload)
159
+
160
+
161
+ def _error_message(raw: bytes) -> Optional[str]:
162
+ """The `{"error": ...}` body an error response is supposed to carry.
163
+ None if it isn't there - a proxy or load balancer in front of the
164
+ control plane may return HTML or nothing at all."""
165
+ try:
166
+ payload = json.loads(raw)
167
+ except (json.JSONDecodeError, UnicodeDecodeError):
168
+ return None
169
+ if isinstance(payload, dict) and isinstance(payload.get("error"), str):
170
+ return str(payload["error"])
171
+ return None
172
+
173
+
174
+ def _parse_mint_response(payload: Any) -> MintCredentialResponse:
175
+ if not isinstance(payload, dict):
176
+ raise ValueError("control plane returned a malformed mint response")
177
+
178
+ provider = str(payload.get("provider", ""))
179
+ raw = payload.get("credentials")
180
+ fields: Dict[str, Any] = raw if isinstance(raw, dict) else {}
181
+
182
+ # Wire format is camelCase - it's produced by the control plane's
183
+ # TypeScript (apps/control-plane/src/routes/credentials.ts).
184
+ if provider == "aws":
185
+ return MintCredentialResponse(
186
+ provider=provider,
187
+ credentials=MintedAWSCredentials(
188
+ access_key_id=str(fields.get("accessKeyId", "")),
189
+ secret_access_key=str(fields.get("secretAccessKey", "")),
190
+ session_token=str(fields.get("sessionToken", "")),
191
+ expiration=str(fields.get("expiration", "")),
192
+ ),
193
+ )
194
+
195
+ if provider == "bitwarden":
196
+ organization_id = fields.get("organizationId")
197
+ return MintCredentialResponse(
198
+ provider=provider,
199
+ credentials=MintedBitwardenCredentials(
200
+ access_token=str(fields.get("accessToken", "")),
201
+ note=str(fields.get("note", "")),
202
+ organization_id=str(organization_id) if organization_id else None,
203
+ ),
204
+ )
205
+
206
+ return MintCredentialResponse(provider=provider)
secrefs/envfile.py ADDED
@@ -0,0 +1,79 @@
1
+ """`.env` file parsing, kept at parity with the Node SDK's `envFile.ts`.
2
+
3
+ A naive line-based `.env` parser treats `#` as a start-of-comment marker
4
+ mid-value, which silently truncates the `#field` fragment off an unquoted
5
+ `sec://provider/path#field` reference - the exact format SecRefs itself
6
+ uses. This module strips inline comments the way `.env` conventionally
7
+ works (including on quoted values), then restores the untruncated value
8
+ for any unquoted `sec://` assignment, since there `#` is URI syntax, not a
9
+ comment marker.
10
+
11
+ Note: because of this, an unquoted `sec://` value can't have a trailing
12
+ inline comment on the same line - put comments on their own line instead.
13
+ Quoted values (`KEY="sec://...#field"`) are unaffected either way.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from typing import Dict
19
+
20
+ # Matches an *unquoted* `KEY=sec://...` assignment, capturing the rest of
21
+ # the line verbatim as the value.
22
+ _UNQUOTED_SEC_REF_LINE = re.compile(
23
+ r"^[ \t]*(?:export[ \t]+)?([A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(sec://\S.*)$"
24
+ )
25
+
26
+
27
+ def _strip_inline_comment(value: str) -> str:
28
+ """Truncates an *unquoted* value at its first `#`, mirroring conventional
29
+ `.env` comment handling (e.g. `dotenv`'s own default behavior)."""
30
+ idx = value.find("#")
31
+ if idx == -1:
32
+ return value.rstrip()
33
+ return value[:idx].rstrip()
34
+
35
+
36
+ def recover_truncated_sec_refs(raw_text: str, parsed: Dict[str, str]) -> Dict[str, str]:
37
+ """Restores the full, untruncated value for any line that assigns an
38
+ unquoted `sec://...` reference, undoing `_strip_inline_comment`'s
39
+ truncation for exactly the cases where `#` is part of the URI rather
40
+ than a comment marker."""
41
+ result = dict(parsed)
42
+ for line in re.split(r"\r?\n", raw_text):
43
+ match = _UNQUOTED_SEC_REF_LINE.match(line)
44
+ if not match:
45
+ continue
46
+ key, value = match.group(1), match.group(2)
47
+ result[key] = value.rstrip()
48
+ return result
49
+
50
+
51
+ def parse_env_file_text(raw_text: str) -> Dict[str, str]:
52
+ """Parses a `.env` file's contents, correctly preserving
53
+ `sec://...#field` fragments."""
54
+ parsed: Dict[str, str] = {}
55
+
56
+ for raw_line in raw_text.splitlines():
57
+ line = raw_line.strip()
58
+ if not line or line.startswith("#") or "=" not in line:
59
+ continue
60
+
61
+ key, _, value = line.partition("=")
62
+ key = key.strip()
63
+ value = value.strip()
64
+ if not key:
65
+ continue
66
+
67
+ if value and value[0] in ("'", '"'):
68
+ # A quoted value ends at its matching closing quote - anything
69
+ # after that (typically a trailing comment) is discarded,
70
+ # whether or not the quotes span the whole rest of the line.
71
+ quote = value[0]
72
+ end = value.find(quote, 1)
73
+ value = value[1:end] if end != -1 else value[1:]
74
+ else:
75
+ value = _strip_inline_comment(value)
76
+
77
+ parsed[key] = value
78
+
79
+ return recover_truncated_sec_refs(raw_text, parsed)
secrefs/parser.py ADDED
@@ -0,0 +1,70 @@
1
+ """
2
+ URI parser for SecRefs' `sec://` reference format:
3
+
4
+ sec://<provider-alias>/<secret-path-or-id>[#<json-field>]
5
+
6
+ sec://aws/prod/db#password
7
+ sec://vault/secret/data/stripe#key
8
+ sec://local/mock-db#password
9
+
10
+ Mirrors packages/node/src/parser.ts exactly (same regex, same semantics),
11
+ so a `sec://` string means the same thing regardless of which SDK reads it.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ from dataclasses import dataclass
18
+ from typing import Optional
19
+
20
+ _SEC_REF_PATTERN = re.compile(r"^sec://([a-zA-Z0-9][a-zA-Z0-9_-]*)/([^\s#]+)(?:#([^\s#]+))?$")
21
+
22
+
23
+ class SecRefParseError(ValueError):
24
+ def __init__(self, raw: str, reason: str) -> None:
25
+ self.raw = raw
26
+ self.reason = reason
27
+ super().__init__(f'Invalid secret reference "{raw}": {reason}')
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class ParsedSecretRef:
32
+ raw: str
33
+ provider: str
34
+ path: str
35
+ field: Optional[str] = None
36
+
37
+
38
+ def is_secret_ref(value: object) -> bool:
39
+ """True if `value` is a string that looks like a `sec://` reference at all."""
40
+ return isinstance(value, str) and value.startswith("sec://")
41
+
42
+
43
+ def parse_secret_ref(raw: object) -> ParsedSecretRef:
44
+ """Parses a `sec://` reference string, raising SecRefParseError on any malformed input."""
45
+ if not isinstance(raw, str):
46
+ raise SecRefParseError(str(raw), "reference must be a string")
47
+
48
+ trimmed = raw.strip()
49
+ if not trimmed.startswith("sec://"):
50
+ raise SecRefParseError(raw, 'must start with "sec://"')
51
+
52
+ match = _SEC_REF_PATTERN.match(trimmed)
53
+ if not match:
54
+ raise SecRefParseError(raw, "does not match sec://<provider>/<path>[#field] format")
55
+
56
+ provider, path, field = match.groups()
57
+ if not provider:
58
+ raise SecRefParseError(raw, "missing provider alias")
59
+ if not path:
60
+ raise SecRefParseError(raw, "missing secret path")
61
+
62
+ return ParsedSecretRef(raw=raw, provider=provider.lower(), path=path, field=field or None)
63
+
64
+
65
+ def try_parse_secret_ref(raw: object) -> Optional[ParsedSecretRef]:
66
+ """Best-effort parse that returns None instead of raising."""
67
+ try:
68
+ return parse_secret_ref(raw)
69
+ except SecRefParseError:
70
+ return None
@@ -0,0 +1,16 @@
1
+ from .aws import AWSSecretsManagerProvider
2
+ from .base import ProviderHealth, SecretFetchError, SecretFetchRequest, SecretProvider
3
+ from .bitwarden import BitwardenProvider
4
+ from .local import LocalProvider
5
+ from .vault import VaultProvider
6
+
7
+ __all__ = [
8
+ "AWSSecretsManagerProvider",
9
+ "BitwardenProvider",
10
+ "LocalProvider",
11
+ "VaultProvider",
12
+ "ProviderHealth",
13
+ "SecretFetchError",
14
+ "SecretFetchRequest",
15
+ "SecretProvider",
16
+ ]