vaultdotenv 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,87 @@
1
+ Metadata-Version: 2.4
2
+ Name: vaultdotenv
3
+ Version: 0.1.0
4
+ Summary: Drop-in python-dotenv replacement. One master key locally, everything else encrypted remotely.
5
+ Author: Matt Redman
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://vaultdotenv.io
8
+ Project-URL: Documentation, https://vaultdotenv.io
9
+ Project-URL: Repository, https://github.com/vaultdotenv/vaultdotenv
10
+ Keywords: dotenv,secrets,environment,vault,encryption
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Topic :: Security
14
+ Classifier: Topic :: Software Development
15
+ Classifier: Programming Language :: Python :: 3
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown
18
+ Requires-Dist: cryptography>=41.0
19
+ Requires-Dist: httpx>=0.24
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest; extra == "dev"
22
+
23
+ # vaultdotenv
24
+
25
+ Drop-in `python-dotenv` replacement. One master key locally, everything else encrypted remotely.
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ pip install vaultdotenv
31
+ ```
32
+
33
+ ## Quick Start
34
+
35
+ Replace `python-dotenv` with one line:
36
+
37
+ ```python
38
+ # Before
39
+ from dotenv import load_dotenv
40
+ load_dotenv()
41
+
42
+ # After
43
+ from vaultdotenv import load_vault
44
+ load_vault()
45
+ ```
46
+
47
+ If a `VAULT_KEY` is found in your `.env` file, secrets are pulled from the vault server and injected into `os.environ`. If no `VAULT_KEY` is present, it behaves exactly like `python-dotenv`.
48
+
49
+ ## CLI
50
+
51
+ The package includes a full CLI (`vde` or `vaultdotenv`):
52
+
53
+ ```bash
54
+ vde init # Initialize a new vault project
55
+ vde push # Push .env secrets to vault
56
+ vde pull # Pull secrets from vault
57
+ vde set KEY "value" # Set a single secret
58
+ vde get KEY # Get a secret (masked)
59
+ vde versions # List secret versions
60
+ vde rollback --version 3 # Rollback to a version
61
+ vde login # Link CLI to dashboard
62
+ ```
63
+
64
+ ## API
65
+
66
+ ```python
67
+ from vaultdotenv import load_vault, load_vault_sync, pull_secrets, push_secrets, watch
68
+
69
+ # Async load (pulls from server, falls back to cache)
70
+ secrets = load_vault()
71
+
72
+ # Sync load (cache only, no network)
73
+ secrets = load_vault_sync()
74
+
75
+ # Direct pull/push
76
+ result = pull_secrets(vault_key, environment="production")
77
+ push_secrets(vault_key, {"DB_URL": "postgres://..."}, environment="production")
78
+
79
+ # Hot reload (polls every 30s)
80
+ watch(on_change=lambda changed, all: print("Updated:", list(changed.keys())))
81
+ ```
82
+
83
+ ## Links
84
+
85
+ - [Documentation](https://vaultdotenv.io)
86
+ - [Dashboard](https://app.vaultdotenv.io)
87
+ - [GitHub](https://github.com/vaultdotenv/vaultdotenv)
@@ -0,0 +1,65 @@
1
+ # vaultdotenv
2
+
3
+ Drop-in `python-dotenv` replacement. One master key locally, everything else encrypted remotely.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install vaultdotenv
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ Replace `python-dotenv` with one line:
14
+
15
+ ```python
16
+ # Before
17
+ from dotenv import load_dotenv
18
+ load_dotenv()
19
+
20
+ # After
21
+ from vaultdotenv import load_vault
22
+ load_vault()
23
+ ```
24
+
25
+ If a `VAULT_KEY` is found in your `.env` file, secrets are pulled from the vault server and injected into `os.environ`. If no `VAULT_KEY` is present, it behaves exactly like `python-dotenv`.
26
+
27
+ ## CLI
28
+
29
+ The package includes a full CLI (`vde` or `vaultdotenv`):
30
+
31
+ ```bash
32
+ vde init # Initialize a new vault project
33
+ vde push # Push .env secrets to vault
34
+ vde pull # Pull secrets from vault
35
+ vde set KEY "value" # Set a single secret
36
+ vde get KEY # Get a secret (masked)
37
+ vde versions # List secret versions
38
+ vde rollback --version 3 # Rollback to a version
39
+ vde login # Link CLI to dashboard
40
+ ```
41
+
42
+ ## API
43
+
44
+ ```python
45
+ from vaultdotenv import load_vault, load_vault_sync, pull_secrets, push_secrets, watch
46
+
47
+ # Async load (pulls from server, falls back to cache)
48
+ secrets = load_vault()
49
+
50
+ # Sync load (cache only, no network)
51
+ secrets = load_vault_sync()
52
+
53
+ # Direct pull/push
54
+ result = pull_secrets(vault_key, environment="production")
55
+ push_secrets(vault_key, {"DB_URL": "postgres://..."}, environment="production")
56
+
57
+ # Hot reload (polls every 30s)
58
+ watch(on_change=lambda changed, all: print("Updated:", list(changed.keys())))
59
+ ```
60
+
61
+ ## Links
62
+
63
+ - [Documentation](https://vaultdotenv.io)
64
+ - [Dashboard](https://app.vaultdotenv.io)
65
+ - [GitHub](https://github.com/vaultdotenv/vaultdotenv)
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "vaultdotenv"
7
+ version = "0.1.0"
8
+ description = "Drop-in python-dotenv replacement. One master key locally, everything else encrypted remotely."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ authors = [{ name = "Matt Redman" }]
13
+ keywords = ["dotenv", "secrets", "environment", "vault", "encryption"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "Topic :: Security",
18
+ "Topic :: Software Development",
19
+ "Programming Language :: Python :: 3",
20
+ ]
21
+ dependencies = [
22
+ "cryptography>=41.0",
23
+ "httpx>=0.24",
24
+ ]
25
+
26
+ [project.optional-dependencies]
27
+ dev = ["pytest"]
28
+
29
+ [project.urls]
30
+ Homepage = "https://vaultdotenv.io"
31
+ Documentation = "https://vaultdotenv.io"
32
+ Repository = "https://github.com/vaultdotenv/vaultdotenv"
33
+
34
+ [project.scripts]
35
+ vaultdotenv = "vaultdotenv.cli:main"
36
+ vde = "vaultdotenv.cli:main"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,16 @@
1
+ """vaultdotenv — Drop-in python-dotenv replacement with remote encrypted secrets."""
2
+
3
+ from vaultdotenv.client import load_vault, load_vault_sync, pull_secrets, push_secrets, watch, unwatch
4
+ from vaultdotenv.device import register_device, load_device_secret, save_device_secret
5
+
6
+ __all__ = [
7
+ "load_vault",
8
+ "load_vault_sync",
9
+ "pull_secrets",
10
+ "push_secrets",
11
+ "watch",
12
+ "unwatch",
13
+ "register_device",
14
+ "load_device_secret",
15
+ "save_device_secret",
16
+ ]
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env python3
2
+ """vaultdotenv CLI — remote secrets manager, drop-in dotenv replacement."""
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+
8
+ def main():
9
+ args = sys.argv[1:]
10
+ command = args[0] if args else None
11
+
12
+ if command == "login":
13
+ from vaultdotenv.commands.auth import login
14
+ login(args)
15
+ elif command == "logout":
16
+ from vaultdotenv.commands.auth import logout
17
+ logout(args)
18
+ elif command == "whoami":
19
+ from vaultdotenv.commands.auth import whoami
20
+ whoami(args)
21
+ elif command == "init":
22
+ from vaultdotenv.commands.init import init
23
+ init(args)
24
+ elif command == "push":
25
+ from vaultdotenv.commands.secrets import push
26
+ push(args)
27
+ elif command == "pull":
28
+ from vaultdotenv.commands.secrets import pull
29
+ pull(args)
30
+ elif command == "set":
31
+ from vaultdotenv.commands.secrets import set_secret
32
+ set_secret(args)
33
+ elif command == "delete":
34
+ from vaultdotenv.commands.secrets import delete
35
+ delete(args)
36
+ elif command == "get":
37
+ from vaultdotenv.commands.secrets import get
38
+ get(args)
39
+ elif command == "versions":
40
+ from vaultdotenv.commands.versions import versions
41
+ versions(args)
42
+ elif command == "rollback":
43
+ from vaultdotenv.commands.versions import rollback
44
+ rollback(args)
45
+ elif command == "register-device":
46
+ from vaultdotenv.commands.devices import register
47
+ register(args)
48
+ elif command == "approve-device":
49
+ from vaultdotenv.commands.devices import approve
50
+ approve(args)
51
+ elif command == "list-devices":
52
+ from vaultdotenv.commands.devices import list_devices
53
+ list_devices(args)
54
+ elif command == "revoke-device":
55
+ from vaultdotenv.commands.devices import revoke
56
+ revoke(args)
57
+ elif command == "key":
58
+ sub = args[1] if len(args) > 1 else None
59
+ if sub == "save":
60
+ from vaultdotenv.commands.keys import save
61
+ save(args[1:])
62
+ elif sub == "list":
63
+ from vaultdotenv.commands.keys import list_keys
64
+ list_keys(args[1:])
65
+ elif sub == "remove":
66
+ from vaultdotenv.commands.keys import remove
67
+ remove(args[1:])
68
+ else:
69
+ print_key_help()
70
+ else:
71
+ print_help()
72
+
73
+
74
+ def print_help():
75
+ print("""vaultdotenv — Remote secrets manager, drop-in dotenv replacement
76
+
77
+ Auth:
78
+ vde login Log in via browser (links CLI to dashboard)
79
+ vde logout Remove saved auth token
80
+ vde whoami Show current logged-in user
81
+
82
+ Usage:
83
+ vde init [--name project] Initialize a new vault project
84
+ vde push [--env production] Push .env secrets to vault
85
+ vde pull [--env staging] Pull secrets from vault
86
+ vde set KEY "value" [--env prod] Set a single secret
87
+ vde delete KEY [--env prod] Remove a secret (with confirmation)
88
+ vde get KEY [--env prod] Get a single secret (masked)
89
+ vde get KEY --raw --token T Reveal cleartext (requires token)
90
+ vde versions [--env prod] List secret versions
91
+ vde rollback --version 5 Rollback to a specific version
92
+
93
+ Device management:
94
+ vde register-device [--name X] Register this machine with the vault
95
+ vde approve-device --id X Approve a pending device
96
+ vde list-devices List all registered devices
97
+ vde revoke-device --id X Revoke a device's access
98
+
99
+ Key management:
100
+ vde key save --project X --key vk_... Save a vault key locally
101
+ vde key list List saved project keys
102
+ vde key remove --project X Remove a saved key
103
+
104
+ Options:
105
+ --project <name> Use saved key for project (from key save)
106
+ --env <name> Environment (default: development)
107
+ --url <url> Vault server URL (default: api.vaultdotenv.io)
108
+ --file <path> Source .env file for push (default: .env)
109
+ --output <path> Output file for pull (default: stdout)
110
+ --name <name> Device or project name
111
+ --id <id> Device ID (for approve/revoke)
112
+ """)
113
+
114
+
115
+ def print_key_help():
116
+ print("""Key management:
117
+ vde key save --project X --key vk_... Save a vault key locally
118
+ vde key list List saved project keys
119
+ vde key remove --project X Remove a saved key
120
+ """)
121
+
122
+
123
+ if __name__ == "__main__":
124
+ try:
125
+ main()
126
+ except Exception as e:
127
+ print(f"Error: {e}", file=sys.stderr)
128
+ sys.exit(1)
@@ -0,0 +1,360 @@
1
+ """Core vaultdotenv client — drop-in replacement for python-dotenv."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import threading
7
+ from pathlib import Path
8
+
9
+ import httpx
10
+
11
+ from vaultdotenv.crypto import decrypt, encrypt, hash_device_secret, parse_vault_key, sign
12
+ from vaultdotenv.device import load_device_secret
13
+
14
+ DEFAULT_VAULT_URL = "https://api.vaultdotenv.io"
15
+ CACHE_FILE = ".vault-cache"
16
+
17
+
18
+ def _parse_dotenv(content: str) -> dict[str, str]:
19
+ """Parse a .env file into key-value pairs."""
20
+ result = {}
21
+ for line in content.splitlines():
22
+ line = line.strip()
23
+ if not line or line.startswith("#"):
24
+ continue
25
+ if "=" not in line:
26
+ continue
27
+ key, _, val = line.partition("=")
28
+ key = key.strip()
29
+ val = val.strip()
30
+ if (val.startswith('"') and val.endswith('"')) or (val.startswith("'") and val.endswith("'")):
31
+ val = val[1:-1]
32
+ result[key] = val
33
+ return result
34
+
35
+
36
+ def _load_cache(vault_key: str, cache_dir: Path, device_secret: str | None) -> dict | None:
37
+ cache_path = cache_dir / CACHE_FILE
38
+ if not cache_path.exists():
39
+ return None
40
+ try:
41
+ encrypted = cache_path.read_text()
42
+ decrypted = decrypt(encrypted, vault_key, device_secret)
43
+ return json.loads(decrypted)
44
+ except Exception:
45
+ return None
46
+
47
+
48
+ def _save_cache(vault_key: str, secrets: dict, cache_dir: Path, device_secret: str | None) -> None:
49
+ cache_path = cache_dir / CACHE_FILE
50
+ encrypted = encrypt(json.dumps(secrets), vault_key, device_secret)
51
+ cache_path.write_text(encrypted)
52
+
53
+
54
+ def pull_secrets(
55
+ vault_key: str,
56
+ environment: str = "development",
57
+ vault_url: str = DEFAULT_VAULT_URL,
58
+ device_secret: str | None = None,
59
+ ) -> dict:
60
+ """Pull secrets from the vault server. Returns {secrets: dict, version: int}."""
61
+ parsed = parse_vault_key(vault_key)
62
+ if not parsed:
63
+ raise ValueError("Invalid VAULT_KEY format. Expected: vk_<projectId>_<secret>")
64
+
65
+ if device_secret is None:
66
+ device_secret = load_device_secret(parsed["project_id"])
67
+
68
+ body_dict = {
69
+ "project_id": parsed["project_id"],
70
+ "environment": environment,
71
+ }
72
+ if device_secret:
73
+ body_dict["device_hash"] = hash_device_secret(device_secret)
74
+
75
+ body = json.dumps(body_dict)
76
+ signature = sign(vault_key, body)
77
+
78
+ resp = httpx.post(
79
+ f"{vault_url}/api/v1/secrets/pull",
80
+ content=body,
81
+ headers={
82
+ "Content-Type": "application/json",
83
+ "X-Vault-Signature": signature,
84
+ },
85
+ )
86
+
87
+ if not resp.is_success:
88
+ if resp.status_code == 403:
89
+ text = resp.text
90
+ if "pending" in text:
91
+ raise RuntimeError("Device not yet approved. Ask the project owner to run: vaultdotenv approve-device")
92
+ raise RuntimeError("Device not registered. Run: vaultdotenv register-device")
93
+ raise RuntimeError(f"Vault pull failed ({resp.status_code}): {resp.text}")
94
+
95
+ data = resp.json()
96
+ decrypted = decrypt(data["secrets"], vault_key, device_secret)
97
+ return {"secrets": json.loads(decrypted), "version": data["version"]}
98
+
99
+
100
+ def push_secrets(
101
+ vault_key: str,
102
+ secrets: dict,
103
+ environment: str = "development",
104
+ vault_url: str = DEFAULT_VAULT_URL,
105
+ device_secret: str | None = None,
106
+ ) -> dict:
107
+ """Push secrets to the vault server. Returns {version: int}."""
108
+ parsed = parse_vault_key(vault_key)
109
+ if not parsed:
110
+ raise ValueError("Invalid VAULT_KEY format. Expected: vk_<projectId>_<secret>")
111
+
112
+ if device_secret is None:
113
+ device_secret = load_device_secret(parsed["project_id"])
114
+
115
+ encrypted_secrets = encrypt(json.dumps(secrets), vault_key, device_secret)
116
+
117
+ body_dict = {
118
+ "project_id": parsed["project_id"],
119
+ "environment": environment,
120
+ "secrets": encrypted_secrets,
121
+ "key_names": list(secrets.keys()),
122
+ }
123
+ if device_secret:
124
+ body_dict["device_hash"] = hash_device_secret(device_secret)
125
+
126
+ body = json.dumps(body_dict)
127
+ signature = sign(vault_key, body)
128
+
129
+ resp = httpx.post(
130
+ f"{vault_url}/api/v1/secrets/push",
131
+ content=body,
132
+ headers={
133
+ "Content-Type": "application/json",
134
+ "X-Vault-Signature": signature,
135
+ },
136
+ )
137
+
138
+ if not resp.is_success:
139
+ raise RuntimeError(f"Vault push failed ({resp.status_code}): {resp.text}")
140
+
141
+ return resp.json()
142
+
143
+
144
+ def _check_version(vault_key: str, environment: str, vault_url: str) -> dict:
145
+ """Lightweight version check — no secrets transferred."""
146
+ parsed = parse_vault_key(vault_key)
147
+ body = json.dumps({"project_id": parsed["project_id"], "environment": environment})
148
+ signature = sign(vault_key, body)
149
+
150
+ resp = httpx.post(
151
+ f"{vault_url}/api/v1/secrets/current-version",
152
+ content=body,
153
+ headers={
154
+ "Content-Type": "application/json",
155
+ "X-Vault-Signature": signature,
156
+ },
157
+ )
158
+ if not resp.is_success:
159
+ raise RuntimeError(f"Version check failed ({resp.status_code})")
160
+ return resp.json()
161
+
162
+
163
+ def load_vault(
164
+ path: str | Path = ".env",
165
+ environment: str | None = None,
166
+ vault_url: str | None = None,
167
+ override: bool = False,
168
+ cache: bool = True,
169
+ ) -> dict:
170
+ """
171
+ Drop-in replacement for dotenv.load_dotenv().
172
+
173
+ Reads VAULT_KEY from .env, pulls secrets from vault server,
174
+ injects into os.environ.
175
+ """
176
+ env_path = Path(path).resolve()
177
+ environment = environment or os.environ.get("NODE_ENV") or os.environ.get("ENVIRONMENT") or "development"
178
+ vault_url = vault_url or os.environ.get("VAULT_URL") or DEFAULT_VAULT_URL
179
+
180
+ # Step 1: Read .env for VAULT_KEY
181
+ vault_key = os.environ.get("VAULT_KEY")
182
+
183
+ if not vault_key and env_path.exists():
184
+ local_env = _parse_dotenv(env_path.read_text())
185
+ vault_key = local_env.get("VAULT_KEY")
186
+
187
+ for key, val in local_env.items():
188
+ if key == "VAULT_KEY":
189
+ continue
190
+ if not override and key in os.environ:
191
+ continue
192
+ os.environ[key] = val
193
+
194
+ # No VAULT_KEY — plain dotenv behavior
195
+ if not vault_key:
196
+ if env_path.exists():
197
+ parsed = _parse_dotenv(env_path.read_text())
198
+ for key, val in parsed.items():
199
+ if not override and key in os.environ:
200
+ continue
201
+ os.environ[key] = val
202
+ return parsed
203
+ return {}
204
+
205
+ # Step 2: Load device secret
206
+ parsed_key = parse_vault_key(vault_key)
207
+ device_secret = load_device_secret(parsed_key["project_id"]) if parsed_key else None
208
+
209
+ # Step 3: Pull from vault
210
+ secrets = None
211
+ version = None
212
+
213
+ try:
214
+ result = pull_secrets(vault_key, environment, vault_url)
215
+ secrets = result["secrets"]
216
+ version = result["version"]
217
+
218
+ if cache:
219
+ try:
220
+ _save_cache(vault_key, secrets, env_path.parent, device_secret)
221
+ except Exception:
222
+ pass
223
+ except Exception as err:
224
+ if cache:
225
+ secrets = _load_cache(vault_key, env_path.parent, device_secret)
226
+ if secrets:
227
+ import sys
228
+ print("[vaultdotenv] Remote fetch failed, using cached secrets", file=sys.stderr)
229
+ else:
230
+ raise RuntimeError(f"[vaultdotenv] Failed to fetch secrets and no cache available: {err}") from err
231
+ else:
232
+ raise
233
+
234
+ # Step 4: Inject into os.environ
235
+ for key, val in secrets.items():
236
+ if not override and key in os.environ:
237
+ continue
238
+ os.environ[key] = str(val)
239
+
240
+ return secrets
241
+
242
+
243
+ def load_vault_sync(path: str | Path = ".env", override: bool = False) -> dict:
244
+ """Synchronous config — reads from cache only (matches Node.js configSync)."""
245
+ env_path = Path(path).resolve()
246
+
247
+ if not env_path.exists():
248
+ return {}
249
+
250
+ local_env = _parse_dotenv(env_path.read_text())
251
+ vault_key = local_env.get("VAULT_KEY")
252
+
253
+ if not vault_key:
254
+ for key, val in local_env.items():
255
+ if not override and key in os.environ:
256
+ continue
257
+ os.environ[key] = val
258
+ return local_env
259
+
260
+ parsed_key = parse_vault_key(vault_key)
261
+ device_secret = load_device_secret(parsed_key["project_id"]) if parsed_key else None
262
+
263
+ cached = _load_cache(vault_key, env_path.parent, device_secret)
264
+ if cached:
265
+ for key, val in cached.items():
266
+ if not override and key in os.environ:
267
+ continue
268
+ os.environ[key] = str(val)
269
+ return cached
270
+
271
+ import sys
272
+ print("[vaultdotenv] No cache available, falling back to local .env", file=sys.stderr)
273
+ for key, val in local_env.items():
274
+ if not override and key in os.environ:
275
+ continue
276
+ os.environ[key] = val
277
+ return local_env
278
+
279
+
280
+ # ── Watch / Hot Reload ────────────────────────────────────────────────────────
281
+
282
+ _watcher_stop = threading.Event()
283
+ _watcher_thread: threading.Thread | None = None
284
+
285
+
286
+ def watch(
287
+ interval: float = 30.0,
288
+ environment: str | None = None,
289
+ vault_url: str | None = None,
290
+ on_change: callable = None,
291
+ on_error: callable = None,
292
+ ):
293
+ """
294
+ Watch for secret changes and hot-reload into os.environ.
295
+
296
+ Usage:
297
+ vault_env.watch(
298
+ interval=30,
299
+ on_change=lambda changed, all_secrets: print("Updated:", changed.keys()),
300
+ )
301
+ """
302
+ global _watcher_thread
303
+
304
+ vault_key = os.environ.get("VAULT_KEY")
305
+ if not vault_key:
306
+ raise RuntimeError("[vaultdotenv] watch() requires VAULT_KEY — call load_vault() first")
307
+
308
+ environment = environment or os.environ.get("NODE_ENV") or os.environ.get("ENVIRONMENT") or "development"
309
+ vault_url = vault_url or os.environ.get("VAULT_URL") or DEFAULT_VAULT_URL
310
+
311
+ parsed_key = parse_vault_key(vault_key)
312
+ device_secret = load_device_secret(parsed_key["project_id"]) if parsed_key else None
313
+
314
+ _watcher_stop.clear()
315
+ current_version = None
316
+
317
+ def _poll():
318
+ nonlocal current_version
319
+
320
+ while not _watcher_stop.is_set():
321
+ try:
322
+ data = _check_version(vault_key, environment, vault_url)
323
+ version = data.get("version", 0)
324
+
325
+ if current_version is None:
326
+ current_version = version
327
+ elif version != current_version:
328
+ result = pull_secrets(vault_key, environment, vault_url)
329
+ current_version = result["version"]
330
+
331
+ changed = {}
332
+ for key, val in result["secrets"].items():
333
+ str_val = str(val)
334
+ if os.environ.get(key) != str_val:
335
+ changed[key] = str_val
336
+ os.environ[key] = str_val
337
+
338
+ if changed and on_change:
339
+ on_change(changed, result["secrets"])
340
+
341
+ except Exception as err:
342
+ if on_error:
343
+ on_error(err)
344
+ else:
345
+ import sys
346
+ print(f"[vaultdotenv] Watch poll failed: {err}", file=sys.stderr)
347
+
348
+ _watcher_stop.wait(interval)
349
+
350
+ _watcher_thread = threading.Thread(target=_poll, daemon=True, name="vaultdotenv-watcher")
351
+ _watcher_thread.start()
352
+
353
+
354
+ def unwatch():
355
+ """Stop the active watcher."""
356
+ global _watcher_thread
357
+ _watcher_stop.set()
358
+ if _watcher_thread:
359
+ _watcher_thread.join(timeout=5)
360
+ _watcher_thread = None
File without changes