yaml-secrets 0.1.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.
@@ -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"
yaml_secrets/loader.py ADDED
@@ -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)
yaml_secrets/vault.py ADDED
@@ -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,8 @@
1
+ yaml_secrets/__init__.py,sha256=oZSF5DMd4eGXeffLymzEiyr_wydqNHTj5zQ8cUpI8pM,783
2
+ yaml_secrets/loader.py,sha256=yFhvYSfdLTCf6yKEc3LHD6qpr34tqI4WOPiVlCNV_n8,5392
3
+ yaml_secrets/vault.py,sha256=inCdSEC5Dqrg7g5mI-N-V52b_ROK3w4n4suKu0S4Y3E,7550
4
+ yaml_secrets-0.1.0.dist-info/licenses/LICENSE,sha256=w1fPKhHdEihb-3_GM7U6VYf0EinqGvqB7yFXwI1u29M,1067
5
+ yaml_secrets-0.1.0.dist-info/METADATA,sha256=xStfc8wDUTnAIdNZV2GF5KHU_DfkeU0VPmio9BkIAVM,1831
6
+ yaml_secrets-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
7
+ yaml_secrets-0.1.0.dist-info/top_level.txt,sha256=YZnVRc9JUx0HG_6pQXcp51bMI_BjXizPWIpBUTKG_vE,13
8
+ yaml_secrets-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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 @@
1
+ yaml_secrets