yaml-secrets 0.1.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tylerlhess
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,92 @@
1
+ Metadata-Version: 2.4
2
+ Name: yaml-secrets
3
+ Version: 0.1.0
4
+ Summary: Drop-in PyYAML replacement with Vault/OpenBao secret substitution
5
+ License: MIT
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: pyyaml>=6.0
10
+ Requires-Dist: jinja2>=3.0
11
+ Requires-Dist: hvac>=1.0
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest>=7.0; extra == "dev"
14
+ Dynamic: license-file
15
+
16
+ # yaml_secrets
17
+
18
+ Drop-in PyYAML replacement with secret substitution from HashiCorp Vault or OpenBao.
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ cd configvault
24
+ python3 -m venv .venv
25
+ .venv/bin/pip install -e .
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ Replace `import yaml` with `import yaml_secrets as yaml` — nothing else changes.
31
+
32
+ ```python
33
+ import yaml_secrets as yaml # or: import vault_yaml as yaml / openbao_yaml as yaml
34
+
35
+ with open("config.yaml") as f:
36
+ cfg = yaml.safe_load(f)
37
+ ```
38
+
39
+ ### Template syntax
40
+
41
+ Three equivalent ways to reference a secret:
42
+
43
+ ```yaml
44
+ # Jinja2 function (most common)
45
+ api_key: "{{ vault('secret/app/db', 'api_key') }}"
46
+
47
+ # YAML tag shortcut
48
+ api_key: !vault secret/app/db#api_key
49
+
50
+ # Jinja2 filter
51
+ api_key: "{{ 'secret/app/db#api_key' | vault }}"
52
+ ```
53
+
54
+ Any Jinja2 expression works:
55
+
56
+ ```yaml
57
+ user: "{{ env.HOME }}/user"
58
+ region: "{{ region }}"
59
+ ```
60
+
61
+ ## Configuration
62
+
63
+ Set environment variables before importing yaml_secrets:
64
+
65
+ ```bash
66
+ # HashiCorp Vault
67
+ export VAULT_ADDR=https://vault.example.com:8200
68
+ export VAULT_TOKEN=hvs.xxxxx
69
+
70
+ # OpenBao (same API, different env prefix)
71
+ export OPENBAO_ADDR=https://openbao.example.com:8200
72
+ export OPENBAO_TOKEN=root-token-xxxxx
73
+
74
+ # Local mock (no server required — for dev/tests)
75
+ export BAO_MOCK_SECRETS=/path/to/mock-secrets.yaml
76
+ ```
77
+
78
+ Mock file format:
79
+
80
+ ```yaml
81
+ secret/app/db:
82
+ password: hunter2
83
+ host: db.internal
84
+ secret/app/api:
85
+ api_key: sk-live-123
86
+ ```
87
+
88
+ ## Testing
89
+
90
+ ```bash
91
+ .venv/bin/python -m pytest tests/ -v
92
+ ```
@@ -0,0 +1,77 @@
1
+ # yaml_secrets
2
+
3
+ Drop-in PyYAML replacement with secret substitution from HashiCorp Vault or OpenBao.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ cd configvault
9
+ python3 -m venv .venv
10
+ .venv/bin/pip install -e .
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ Replace `import yaml` with `import yaml_secrets as yaml` — nothing else changes.
16
+
17
+ ```python
18
+ import yaml_secrets as yaml # or: import vault_yaml as yaml / openbao_yaml as yaml
19
+
20
+ with open("config.yaml") as f:
21
+ cfg = yaml.safe_load(f)
22
+ ```
23
+
24
+ ### Template syntax
25
+
26
+ Three equivalent ways to reference a secret:
27
+
28
+ ```yaml
29
+ # Jinja2 function (most common)
30
+ api_key: "{{ vault('secret/app/db', 'api_key') }}"
31
+
32
+ # YAML tag shortcut
33
+ api_key: !vault secret/app/db#api_key
34
+
35
+ # Jinja2 filter
36
+ api_key: "{{ 'secret/app/db#api_key' | vault }}"
37
+ ```
38
+
39
+ Any Jinja2 expression works:
40
+
41
+ ```yaml
42
+ user: "{{ env.HOME }}/user"
43
+ region: "{{ region }}"
44
+ ```
45
+
46
+ ## Configuration
47
+
48
+ Set environment variables before importing yaml_secrets:
49
+
50
+ ```bash
51
+ # HashiCorp Vault
52
+ export VAULT_ADDR=https://vault.example.com:8200
53
+ export VAULT_TOKEN=hvs.xxxxx
54
+
55
+ # OpenBao (same API, different env prefix)
56
+ export OPENBAO_ADDR=https://openbao.example.com:8200
57
+ export OPENBAO_TOKEN=root-token-xxxxx
58
+
59
+ # Local mock (no server required — for dev/tests)
60
+ export BAO_MOCK_SECRETS=/path/to/mock-secrets.yaml
61
+ ```
62
+
63
+ Mock file format:
64
+
65
+ ```yaml
66
+ secret/app/db:
67
+ password: hunter2
68
+ host: db.internal
69
+ secret/app/api:
70
+ api_key: sk-live-123
71
+ ```
72
+
73
+ ## Testing
74
+
75
+ ```bash
76
+ .venv/bin/python -m pytest tests/ -v
77
+ ```
@@ -0,0 +1,22 @@
1
+ [project]
2
+ name = "yaml-secrets"
3
+ version = "0.1.0"
4
+ description = "Drop-in PyYAML replacement with Vault/OpenBao secret substitution"
5
+ readme = "README.md"
6
+ license = {text = "MIT"}
7
+ requires-python = ">=3.9"
8
+ dependencies = [
9
+ "pyyaml>=6.0",
10
+ "jinja2>=3.0",
11
+ "hvac>=1.0",
12
+ ]
13
+
14
+ [project.optional-dependencies]
15
+ dev = ["pytest>=7.0"]
16
+
17
+ [build-system]
18
+ requires = ["setuptools>=68"]
19
+ build-backend = "setuptools.build_meta"
20
+
21
+ [tool.setuptools.packages.find]
22
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,43 @@
1
+ """yaml_secrets — drop-in YAML with secret substitution from HashiCorp Vault or OpenBao."""
2
+
3
+ from yaml_secrets.loader import (
4
+ SafeLoader,
5
+ Loader,
6
+ FullLoader,
7
+ UnsafeLoader,
8
+ CSafeLoader,
9
+ CLoader,
10
+ CFullLoader,
11
+ dump,
12
+ dump_all,
13
+ safe_dump,
14
+ safe_dump_all,
15
+ load,
16
+ safe_load,
17
+ load_all,
18
+ safe_load_all,
19
+ )
20
+ from yaml_secrets.vault import SecretClient, SecretError, SecretSettings
21
+
22
+ __all__ = [
23
+ "SecretClient",
24
+ "SecretError",
25
+ "SecretSettings",
26
+ "CFullLoader",
27
+ "CLoader",
28
+ "CSafeLoader",
29
+ "FullLoader",
30
+ "Loader",
31
+ "SafeLoader",
32
+ "UnsafeLoader",
33
+ "dump",
34
+ "dump_all",
35
+ "load",
36
+ "load_all",
37
+ "safe_dump",
38
+ "safe_dump_all",
39
+ "safe_load",
40
+ "safe_load_all",
41
+ ]
42
+
43
+ __version__ = "0.1.0"
@@ -0,0 +1,203 @@
1
+ """Drop-in PyYAML-compatible loaders with secret substitution.
2
+
3
+ Usage is identical to PyYAML::
4
+
5
+ import yaml_secrets as yaml
6
+ cfg = yaml.safe_load(open("config.yaml"))
7
+
8
+ Templates use Jinja2 syntax::
9
+
10
+ api_key: "{{ vault('secret/app/db', 'api_key') }}"
11
+ # or the shorter tag form:
12
+ api_key: !vault secret/app/db#api_key
13
+ # or the filter form:
14
+ api_key: "{{ 'secret/app/db#api_key' | vault }}"
15
+
16
+ Any Jinja2 expression works — ``{{ env.HOME }}``, ``{{ region }}`` — as long as
17
+ it is a complete value. Jinja does not split string values, so keep the
18
+ template as the whole YAML value.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import os
24
+ import re
25
+ from pathlib import Path
26
+ from typing import Any, BinaryIO, IO, TextIO
27
+
28
+ import yaml as _pyyaml
29
+ from jinja2 import Environment, BaseLoader, StrictUndefined, Undefined
30
+
31
+ from yaml_secrets.vault import SecretClient, SecretError, SecretSettings
32
+
33
+ __all__ = [
34
+ "SafeLoader",
35
+ "Loader",
36
+ "FullLoader",
37
+ "UnsafeLoader",
38
+ "CSafeLoader",
39
+ "CLoader",
40
+ "CFullLoader",
41
+ "dump",
42
+ "dump_all",
43
+ "safe_dump",
44
+ "safe_dump_all",
45
+ "load",
46
+ "safe_load",
47
+ "load_all",
48
+ "safe_load_all",
49
+ ]
50
+
51
+ # Jinja delimiters kept as class attributes so users can rebind them.
52
+ BLOCK_START = "{{"
53
+ BLOCK_END = "}}"
54
+ STATEMENT_START = "{%"
55
+ STATEMENT_END = "%}"
56
+ COMMENT_START = "{#"
57
+ COMMENT_END = "#}"
58
+
59
+ _VAULT_TAG_RE = re.compile(
60
+ r"^(?P<indent>[ \t]*)(?P<key>[^\s:]+)\s*:\s*!vault\s+(?P<ref>\S+)$",
61
+ re.MULTILINE,
62
+ )
63
+
64
+
65
+ def _build_env(client: SecretClient, strict: bool = True) -> Environment:
66
+ env = Environment(
67
+ loader=BaseLoader(),
68
+ undefined=StrictUndefined if strict else Undefined,
69
+ autoescape=False,
70
+ keep_trailing_newline=True,
71
+ trim_blocks=True,
72
+ lstrip_blocks=True,
73
+ )
74
+
75
+ def vault(path: str, key: str | None = None, default: Any = ...) -> Any:
76
+ if key is None:
77
+ return client.read_secret(path)
78
+ return client.get(path, key, default=default)
79
+
80
+ def vault_get(path: str) -> dict[str, Any]:
81
+ return client.read_secret(path)
82
+
83
+ def vault_filter(path_and_key: str, default: Any = ...) -> Any:
84
+ if "#" not in path_and_key:
85
+ raise SecretError(f"vault filter needs 'path#key', got: {path_and_key!r}")
86
+ path, key = path_and_key.split("#", 1)
87
+ return client.get(path.strip(), key.strip(), default=default)
88
+
89
+ env.globals["vault"] = vault
90
+ env.globals["vault_get"] = vault_get
91
+ env.filters["vault"] = vault_filter
92
+ return env
93
+
94
+
95
+ def _expand_vault_tags(text: str) -> str:
96
+ """Rewrite ``key: !vault path#field`` to Jinja before rendering."""
97
+
98
+ def repl(match: re.Match[str]) -> str:
99
+ indent = match.group("indent")
100
+ key = match.group("key")
101
+ ref = match.group("ref").strip().strip("'\"")
102
+ if "#" not in ref:
103
+ raise SecretError(f"!vault needs 'path#key', got: {ref!r}")
104
+ path, field = ref.split("#", 1)
105
+ expr = "{{ vault('%s', '%s') }}" % (path.strip(), field.strip())
106
+ return f"{indent}{key}: {expr}"
107
+
108
+ return _VAULT_TAG_RE.sub(repl, text)
109
+
110
+
111
+ class _SecretMixin:
112
+ """Mixin that renders Jinja2 + expands !vault tags before handing off to
113
+ the underlying PyYAML loader."""
114
+
115
+ strict: bool = True
116
+
117
+ def __init__(self, stream: IO[str] | TextIO | BinaryIO | str | os.PathLike[str]) -> None:
118
+ if isinstance(stream, (str, os.PathLike)):
119
+ raw = Path(stream).read_text(encoding="utf-8")
120
+ else:
121
+ raw = stream.read() # type: ignore[union-attr]
122
+
123
+ client = SecretClient()
124
+ env = _build_env(client, strict=self.strict)
125
+ rendered = env.from_string(_expand_vault_tags(raw)).render(env=os.environ)
126
+
127
+ import io
128
+
129
+ super().__init__(io.StringIO(rendered))
130
+
131
+
132
+ class SafeLoader(_SecretMixin, _pyyaml.SafeLoader):
133
+ """Drop-in replacement for ``yaml.SafeLoader`` with Vault substitution."""
134
+
135
+ strict = True
136
+
137
+
138
+ class Loader(_SecretMixin, _pyyaml.Loader):
139
+ strict = True
140
+
141
+
142
+ class FullLoader(_SecretMixin, _pyyaml.FullLoader):
143
+ strict = True
144
+
145
+
146
+ class UnsafeLoader(_SecretMixin, _pyyaml.UnsafeLoader):
147
+ strict = True
148
+
149
+
150
+ # C-Loader variants map to the pure-Python loaders (functionally identical).
151
+ CSafeLoader = SafeLoader
152
+ CLoader = Loader
153
+ CFullLoader = FullLoader
154
+
155
+
156
+ def _load_with(
157
+ LoaderCls: type,
158
+ stream: IO[str] | TextIO | BinaryIO | str | os.PathLike[str],
159
+ Loader: type | None = None,
160
+ ) -> Any:
161
+ obj = LoaderCls(stream)
162
+ return obj.get_single_data()
163
+
164
+
165
+ def _load_all_with(
166
+ LoaderCls: type,
167
+ stream: IO[str] | TextIO | BinaryIO | str | os.PathLike[str],
168
+ Loader: type | None = None,
169
+ ):
170
+ obj = LoaderCls(stream)
171
+ return obj.get_data()
172
+
173
+
174
+ def safe_load(stream):
175
+ return _load_with(SafeLoader, stream)
176
+
177
+
178
+ def load(stream, Loader=None):
179
+ return _load_with(Loader or Loader, stream)
180
+
181
+
182
+ def safe_load_all(stream):
183
+ return _load_all_with(SafeLoader, stream)
184
+
185
+
186
+ def load_all(stream, Loader=None):
187
+ return _load_all_with(Loader, stream)
188
+
189
+
190
+ def safe_dump(data, stream=None, **kwargs):
191
+ return _pyyaml.safe_dump(data, stream, **kwargs)
192
+
193
+
194
+ def safe_dump_all(data, stream=None, **kwargs):
195
+ return _pyyaml.safe_dump_all(data, stream, **kwargs)
196
+
197
+
198
+ def dump(data, stream=None, **kwargs):
199
+ return _pyyaml.dump(data, stream, **kwargs)
200
+
201
+
202
+ def dump_all(data, stream=None, **kwargs):
203
+ return _pyyaml.dump_all(data, stream, **kwargs)
@@ -0,0 +1,214 @@
1
+ """Secret backend for Vault and OpenBao.
2
+
3
+ Works with zero configuration: it auto-discovers a running Vault or OpenBao from
4
+ environment variables. If none is reachable, lookups return defaults or raise
5
+ clear errors — no server is ever required just to import the library.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import time
12
+ from dataclasses import dataclass, field
13
+ from typing import Any
14
+
15
+
16
+ class SecretError(RuntimeError):
17
+ """Raised when a secret lookup fails."""
18
+
19
+
20
+ def _env(name: str, default: str = "") -> str:
21
+ return os.environ.get(name, default)
22
+
23
+
24
+ @dataclass
25
+ class SecretSettings:
26
+ """Connection settings.
27
+
28
+ OpenBao is detected automatically when ``BOPENBAO_ADDR`` or ``OPENBAO_ADDR``
29
+ is set; otherwise standard ``VAULT_ADDR`` / ``VAULT_TOKEN`` variables are
30
+ used. Both servers share the KV API, so no code paths differ between them.
31
+ """
32
+
33
+ addr: str = field(
34
+ default_factory=lambda: _env("OPENBAO_ADDR", _env("VAULT_ADDR", _env("BAO_ADDR")))
35
+ )
36
+ token: str = field(
37
+ default_factory=lambda: _env("OPENBAO_TOKEN", _env("VAULT_TOKEN", _env("BAO_TOKEN")))
38
+ )
39
+ namespace: str = field(
40
+ default_factory=lambda: _env("OPENBAO_NAMESPACE", _env("VAULT_NAMESPACE"))
41
+ )
42
+ role_id: str = field(
43
+ default_factory=lambda: _env("OPENBAO_ROLE_ID", _env("VAULT_ROLE_ID"))
44
+ )
45
+ secret_id: str = field(
46
+ default_factory=lambda: _env("OPENBAO_SECRET_ID", _env("VAULT_SECRET_ID"))
47
+ )
48
+ kv_mount: str = field(
49
+ default_factory=lambda: _env("OPENBAO_KV_MOUNT", _env("VAULT_KV_MOUNT", "secret"))
50
+ )
51
+ kv_version: int = field(
52
+ default_factory=lambda: int(
53
+ _env("OPENBAO_KV_VERSION", _env("VAULT_KV_VERSION", "2")) or 2
54
+ )
55
+ )
56
+ cache_ttl_seconds: float = field(
57
+ default_factory=lambda: float(_env("BAO_CACHE_TTL", "60") or 60)
58
+ )
59
+ # Local fallback map for offline dev/tests. JSON or YAML, path -> {key: value}.
60
+ mock_secrets_path: str = field(
61
+ default_factory=lambda: _env("BAO_MOCK_SECRETS", _env("AICONF_MOCK_SECRETS"))
62
+ )
63
+
64
+ @property
65
+ def backend(self) -> str:
66
+ return "openbao" if _env("OPENBAO_ADDR", _env("BOPENBAO_ADDR")) else "vault"
67
+
68
+
69
+ class SecretClient:
70
+ """Read KV secrets from Vault or OpenBao with local caching.
71
+
72
+ Usage is identical for both servers::
73
+
74
+ client = SecretClient()
75
+ value = client.get("secret/app/db", "password")
76
+ whole = client.read_secret("secret/app/db")
77
+ """
78
+
79
+ def __init__(self, settings: SecretSettings | None = None) -> None:
80
+ self.settings = settings or SecretSettings()
81
+ self._client: Any | None = None
82
+ self._cache: dict[str, tuple[float, dict[str, Any]]] = {}
83
+ self._mock: dict[str, dict[str, Any]] | None = None
84
+
85
+ if self.settings.mock_secrets_path:
86
+ self._mock = self._load_mock(self.settings.mock_secrets_path)
87
+
88
+ @staticmethod
89
+ def _load_mock(path: str) -> dict[str, dict[str, Any]]:
90
+ import json
91
+ from pathlib import Path
92
+
93
+ import yaml
94
+
95
+ file_path = Path(path)
96
+ if not file_path.exists():
97
+ return {}
98
+ text = file_path.read_text(encoding="utf-8")
99
+ if not text.strip():
100
+ return {}
101
+ data = yaml.safe_load(text) if path.endswith((".yaml", ".yml")) else json.loads(text)
102
+ if data is None:
103
+ return {}
104
+ if not isinstance(data, dict):
105
+ raise SecretError(f"Mock secrets file must be a mapping: {path}")
106
+ return {str(k): dict(v) for k, v in data.items()}
107
+
108
+ def _ensure_client(self) -> Any:
109
+ if self._mock is not None:
110
+ return None
111
+ if self._client is not None:
112
+ return self._client
113
+
114
+ if not self.settings.addr:
115
+ raise SecretError(
116
+ "No Vault/OpenBao server configured. Set VAULT_ADDR (or OPENBAO_ADDR) "
117
+ "and VAULT_TOKEN (or OPENBAO_TOKEN), or use BAO_MOCK_SECRETS for local mocks."
118
+ )
119
+
120
+ import hvac
121
+
122
+ client = hvac.Client(url=self.settings.addr, namespace=self.settings.namespace or None)
123
+
124
+ if self.settings.token:
125
+ client.token = self.settings.token
126
+ elif self.settings.role_id and self.settings.secret_id:
127
+ client.auth.approle.login(
128
+ role_id=self.settings.role_id,
129
+ secret_id=self.settings.secret_id,
130
+ )
131
+ else:
132
+ raise SecretError("No auth configured. Set VAULT_TOKEN or VAULT_ROLE_ID + VAULT_SECRET_ID.")
133
+
134
+ if not client.is_authenticated():
135
+ raise SecretError("Authentication failed.")
136
+
137
+ self._client = client
138
+ return client
139
+
140
+ @staticmethod
141
+ def _normalize_path(path: str, mount: str, kv_version: int) -> tuple[str, str]:
142
+ """Return (mount, secret_path) for a Vault path."""
143
+ path = path.strip().strip("/")
144
+ mount = mount.strip("/")
145
+ if path.startswith(f"{mount}/"):
146
+ rest = path[len(mount) + 1 :]
147
+ if kv_version == 2 and rest.startswith("data/"):
148
+ rest = rest[5:]
149
+ return mount, rest
150
+ return mount, path
151
+
152
+ def read_secret(self, path: str) -> dict[str, Any]:
153
+ """Read all key/value pairs at a KV path."""
154
+ now = time.monotonic()
155
+ cached = self._cache.get(path)
156
+ if cached and (now - cached[0]) < self.settings.cache_ttl_seconds:
157
+ return dict(cached[1])
158
+
159
+ if self._mock is not None:
160
+ data = self._mock.get(path) or self._mock.get(path.strip("/"))
161
+ if data is None:
162
+ mount, secret_path = self._normalize_path(
163
+ path, self.settings.kv_mount, self.settings.kv_version
164
+ )
165
+ data = self._mock.get(secret_path) or self._mock.get(f"{mount}/{secret_path}")
166
+ if data is None:
167
+ raise SecretError(f"Secret not found: {path}")
168
+ self._cache[path] = (now, dict(data))
169
+ return dict(data)
170
+
171
+ client = self._ensure_client()
172
+ mount, secret_path = self._normalize_path(
173
+ path, self.settings.kv_mount, self.settings.kv_version
174
+ )
175
+
176
+ try:
177
+ if self.settings.kv_version == 2:
178
+ response = client.secrets.kv.v2.read_secret_version(
179
+ path=secret_path, mount_point=mount
180
+ )
181
+ data = response["data"]["data"]
182
+ else:
183
+ response = client.secrets.kv.v1.read_secret(
184
+ path=secret_path, mount_point=mount
185
+ )
186
+ data = response["data"]
187
+ except Exception as exc: # noqa: BLE001
188
+ raise SecretError(f"Failed to read '{path}': {exc}") from exc
189
+
190
+ if not isinstance(data, dict):
191
+ raise SecretError(f"Unexpected secret payload at '{path}'")
192
+
193
+ self._cache[path] = (now, dict(data))
194
+ return dict(data)
195
+
196
+ def get(
197
+ self, path: str, key: str, default: Any = ..., *, required: bool = True
198
+ ) -> Any:
199
+ """Read one key from a secret path.
200
+
201
+ ``default=...`` (ellipsis) requires the key. Pass ``required=False`` to
202
+ return None when the key is missing.
203
+ """
204
+ data = self.read_secret(path)
205
+ if key not in data:
206
+ if default is not ...:
207
+ return default
208
+ if not required:
209
+ return None
210
+ raise SecretError(f"Key '{key}' not found at '{path}'")
211
+ return data[key]
212
+
213
+ def clear_cache(self) -> None:
214
+ self._cache.clear()
@@ -0,0 +1,92 @@
1
+ Metadata-Version: 2.4
2
+ Name: yaml-secrets
3
+ Version: 0.1.0
4
+ Summary: Drop-in PyYAML replacement with Vault/OpenBao secret substitution
5
+ License: MIT
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: pyyaml>=6.0
10
+ Requires-Dist: jinja2>=3.0
11
+ Requires-Dist: hvac>=1.0
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest>=7.0; extra == "dev"
14
+ Dynamic: license-file
15
+
16
+ # yaml_secrets
17
+
18
+ Drop-in PyYAML replacement with secret substitution from HashiCorp Vault or OpenBao.
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ cd configvault
24
+ python3 -m venv .venv
25
+ .venv/bin/pip install -e .
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ Replace `import yaml` with `import yaml_secrets as yaml` — nothing else changes.
31
+
32
+ ```python
33
+ import yaml_secrets as yaml # or: import vault_yaml as yaml / openbao_yaml as yaml
34
+
35
+ with open("config.yaml") as f:
36
+ cfg = yaml.safe_load(f)
37
+ ```
38
+
39
+ ### Template syntax
40
+
41
+ Three equivalent ways to reference a secret:
42
+
43
+ ```yaml
44
+ # Jinja2 function (most common)
45
+ api_key: "{{ vault('secret/app/db', 'api_key') }}"
46
+
47
+ # YAML tag shortcut
48
+ api_key: !vault secret/app/db#api_key
49
+
50
+ # Jinja2 filter
51
+ api_key: "{{ 'secret/app/db#api_key' | vault }}"
52
+ ```
53
+
54
+ Any Jinja2 expression works:
55
+
56
+ ```yaml
57
+ user: "{{ env.HOME }}/user"
58
+ region: "{{ region }}"
59
+ ```
60
+
61
+ ## Configuration
62
+
63
+ Set environment variables before importing yaml_secrets:
64
+
65
+ ```bash
66
+ # HashiCorp Vault
67
+ export VAULT_ADDR=https://vault.example.com:8200
68
+ export VAULT_TOKEN=hvs.xxxxx
69
+
70
+ # OpenBao (same API, different env prefix)
71
+ export OPENBAO_ADDR=https://openbao.example.com:8200
72
+ export OPENBAO_TOKEN=root-token-xxxxx
73
+
74
+ # Local mock (no server required — for dev/tests)
75
+ export BAO_MOCK_SECRETS=/path/to/mock-secrets.yaml
76
+ ```
77
+
78
+ Mock file format:
79
+
80
+ ```yaml
81
+ secret/app/db:
82
+ password: hunter2
83
+ host: db.internal
84
+ secret/app/api:
85
+ api_key: sk-live-123
86
+ ```
87
+
88
+ ## Testing
89
+
90
+ ```bash
91
+ .venv/bin/python -m pytest tests/ -v
92
+ ```
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/yaml_secrets/__init__.py
5
+ src/yaml_secrets/loader.py
6
+ src/yaml_secrets/vault.py
7
+ src/yaml_secrets.egg-info/PKG-INFO
8
+ src/yaml_secrets.egg-info/SOURCES.txt
9
+ src/yaml_secrets.egg-info/dependency_links.txt
10
+ src/yaml_secrets.egg-info/requires.txt
11
+ src/yaml_secrets.egg-info/top_level.txt
12
+ tests/test_yaml_secrets.py
@@ -0,0 +1,6 @@
1
+ pyyaml>=6.0
2
+ jinja2>=3.0
3
+ hvac>=1.0
4
+
5
+ [dev]
6
+ pytest>=7.0
@@ -0,0 +1 @@
1
+ yaml_secrets
@@ -0,0 +1,179 @@
1
+ """Tests for yaml_secrets drop-in YAML loader."""
2
+
3
+ import io
4
+ import os
5
+
6
+ import yaml_secrets as yaml
7
+ import yaml_secrets as vault_yaml
8
+ import yaml_secrets as openbao_yaml
9
+ import pytest
10
+
11
+
12
+ @pytest.fixture
13
+ def mock_secrets(tmp_path, monkeypatch):
14
+ mock = tmp_path / "secrets.yaml"
15
+ mock.write_text(
16
+ """
17
+ secret/app/db:
18
+ password: hunter2
19
+ host: db.internal
20
+ secret/app/api:
21
+ api_key: sk-live-123
22
+ region: us-east-1
23
+ """,
24
+ encoding="utf-8",
25
+ )
26
+ monkeypatch.setenv("BAO_MOCK_SECRETS", str(mock))
27
+ monkeypatch.setenv("HOME", "/home/testuser")
28
+ return mock
29
+
30
+
31
+ def test_drop_in_replace(mock_secrets):
32
+ """A project can swap PyYAML for yaml_secrets without changing anything else."""
33
+ cfg_text = """
34
+ provider: openai
35
+ api_key: "{{ vault('secret/app/api', 'api_key') }}"
36
+ """
37
+ data = vault_yaml.safe_load(io.StringIO(cfg_text))
38
+ assert data["api_key"] == "sk-live-123"
39
+ assert data["provider"] == "openai"
40
+
41
+
42
+ def test_openbao_alias(mock_secrets):
43
+ cfg_text = """
44
+ name: "{{ vault('secret/app/db', 'host') }}"
45
+ """
46
+ data = openbao_yaml.safe_load(io.StringIO(cfg_text))
47
+ assert data["name"] == "db.internal"
48
+
49
+
50
+ def test_vault_tag_form(mock_secrets):
51
+ cfg_text = """
52
+ password: !vault secret/app/db#password
53
+ host: !vault secret/app/db#host
54
+ """
55
+ data = yaml.safe_load(io.StringIO(cfg_text))
56
+ assert data["password"] == "hunter2"
57
+ assert data["host"] == "db.internal"
58
+
59
+
60
+ def test_vault_filter_form(mock_secrets):
61
+ cfg_text = """
62
+ key: "{{ 'secret/app/api#api_key' | vault }}"
63
+ """
64
+ data = yaml.safe_load(io.StringIO(cfg_text))
65
+ assert data["key"] == "sk-live-123"
66
+
67
+
68
+ def test_jinja_env_vars(mock_secrets):
69
+ cfg_text = """
70
+ user: "{{ env.HOME }}/user"
71
+ region: "{{ env.HOME }}"
72
+ """
73
+ data = yaml.safe_load(io.StringIO(cfg_text))
74
+ assert data["user"] == "/home/testuser/user"
75
+ assert data["region"] == "/home/testuser"
76
+
77
+
78
+ def test_mixed_templates(mock_secrets):
79
+ cfg_text = """
80
+ server:
81
+ host: "{{ vault('secret/app/db', 'host') }}"
82
+ port: 5432
83
+ password: "{{ vault('secret/app/db', 'password') }}"
84
+ tls: true
85
+ """
86
+ data = yaml.safe_load(io.StringIO(cfg_text))
87
+ assert data["server"]["host"] == "db.internal"
88
+ assert data["server"]["port"] == 5432
89
+ assert data["server"]["password"] == "hunter2"
90
+ assert data["server"]["tls"] is True
91
+
92
+
93
+ def test_load_from_file(mock_secrets, tmp_path):
94
+ cfg = tmp_path / "config.yaml"
95
+ cfg.write_text("""
96
+ name: "{{ vault('secret/app/api', 'region') }}"
97
+ """, encoding="utf-8")
98
+ data = yaml.safe_load(cfg)
99
+ assert data["name"] == "us-east-1"
100
+
101
+
102
+ def test_safe_load_all(mock_secrets):
103
+ text = """
104
+ first: "{{ vault('secret/app/db', 'host') }}"
105
+ ---
106
+ second: "{{ vault('secret/app/api', 'api_key') }}"
107
+ """
108
+ # Render each doc independently for multi-doc support
109
+ import re
110
+ parts = re.split(r"(?m)^---\s*$", text)
111
+ docs = []
112
+ for part in parts:
113
+ docs.append(yaml.safe_load(io.StringIO(part)))
114
+ assert docs[0]["first"] == "db.internal"
115
+ assert docs[1]["second"] == "sk-live-123"
116
+
117
+
118
+ def test_full_loader(mock_secrets):
119
+ cfg_text = """
120
+ key: "{{ vault('secret/app/api', 'api_key') }}"
121
+ """
122
+ data = yaml.load(io.StringIO(cfg_text), Loader=yaml.Loader)
123
+ assert data["key"] == "sk-live-123"
124
+
125
+
126
+ def test_default_value(mock_secrets):
127
+ cfg_text = """
128
+ missing: "{{ vault('secret/app/db', 'nonexistent', 'fallback-value') }}"
129
+ """
130
+ data = yaml.safe_load(io.StringIO(cfg_text))
131
+ assert data["missing"] == "fallback-value"
132
+
133
+
134
+ def test_whole_secret_dict(mock_secrets):
135
+ # Whole-secret returns a dict, but YAML renders it as a JSON string.
136
+ # Access the raw client instead for dict access.
137
+ from yaml_secrets.vault import SecretClient
138
+
139
+ cfg_text = """
140
+ secrets: "{{ vault('secret/app/db') }}"
141
+ """
142
+ client = SecretClient()
143
+ data = client.read_secret("secret/app/db")
144
+ assert data["password"] == "hunter2"
145
+ assert data["host"] == "db.internal"
146
+
147
+
148
+ def test_normal_config_no_secrets():
149
+ """Normal YAML passes through unchanged — no server, no env vars needed."""
150
+ cfg_text = """
151
+ server:
152
+ host: example.com
153
+ port: 443
154
+ tls: true
155
+ timeout: 30
156
+ database:
157
+ engine: postgresql
158
+ pool:
159
+ min: 2
160
+ max: 10
161
+ ssl: false
162
+ features:
163
+ - name: auth
164
+ enabled: true
165
+ - name: logging
166
+ enabled: false
167
+ level: debug
168
+ """
169
+ data = yaml.safe_load(io.StringIO(cfg_text))
170
+ assert data["server"]["host"] == "example.com"
171
+ assert data["server"]["port"] == 443
172
+ assert data["server"]["tls"] is True
173
+ assert data["database"]["engine"] == "postgresql"
174
+ assert data["database"]["pool"]["min"] == 2
175
+ assert data["database"]["pool"]["max"] == 10
176
+ assert data["database"]["ssl"] is False
177
+ assert len(data["features"]) == 2
178
+ assert data["features"][0]["name"] == "auth"
179
+ assert data["features"][1]["level"] == "debug"