better-python-doppler 2.0.0__tar.gz → 2.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.
Files changed (27) hide show
  1. {better_python_doppler-2.0.0 → better_python_doppler-2.1.0}/PKG-INFO +21 -1
  2. {better_python_doppler-2.0.0 → better_python_doppler-2.1.0}/README.md +20 -0
  3. {better_python_doppler-2.0.0 → better_python_doppler-2.1.0}/pyproject.toml +1 -1
  4. {better_python_doppler-2.0.0 → better_python_doppler-2.1.0}/src/better_python_doppler/doppler_sdk.py +22 -0
  5. better_python_doppler-2.1.0/src/better_python_doppler/resources/secrets.py +606 -0
  6. {better_python_doppler-2.0.0 → better_python_doppler-2.1.0}/src/better_python_doppler.egg-info/PKG-INFO +21 -1
  7. better_python_doppler-2.0.0/src/better_python_doppler/resources/secrets.py +0 -306
  8. {better_python_doppler-2.0.0 → better_python_doppler-2.1.0}/LICENSE +0 -0
  9. {better_python_doppler-2.0.0 → better_python_doppler-2.1.0}/setup.cfg +0 -0
  10. {better_python_doppler-2.0.0 → better_python_doppler-2.1.0}/src/better_python_doppler/__init__.py +0 -0
  11. {better_python_doppler-2.0.0 → better_python_doppler-2.1.0}/src/better_python_doppler/apis/__init__.py +0 -0
  12. {better_python_doppler-2.0.0 → better_python_doppler-2.1.0}/src/better_python_doppler/apis/secret_apis.py +0 -0
  13. {better_python_doppler-2.0.0 → better_python_doppler-2.1.0}/src/better_python_doppler/exceptions.py +0 -0
  14. {better_python_doppler-2.0.0 → better_python_doppler-2.1.0}/src/better_python_doppler/models/__init__.py +0 -0
  15. {better_python_doppler-2.0.0 → better_python_doppler-2.1.0}/src/better_python_doppler/models/secret.py +0 -0
  16. {better_python_doppler-2.0.0 → better_python_doppler-2.1.0}/src/better_python_doppler/resources/__init__.py +0 -0
  17. {better_python_doppler-2.0.0 → better_python_doppler-2.1.0}/src/better_python_doppler/secret.py +0 -0
  18. {better_python_doppler-2.0.0 → better_python_doppler-2.1.0}/src/better_python_doppler/transport.py +0 -0
  19. {better_python_doppler-2.0.0 → better_python_doppler-2.1.0}/src/better_python_doppler.egg-info/SOURCES.txt +0 -0
  20. {better_python_doppler-2.0.0 → better_python_doppler-2.1.0}/src/better_python_doppler.egg-info/dependency_links.txt +0 -0
  21. {better_python_doppler-2.0.0 → better_python_doppler-2.1.0}/src/better_python_doppler.egg-info/requires.txt +0 -0
  22. {better_python_doppler-2.0.0 → better_python_doppler-2.1.0}/src/better_python_doppler.egg-info/top_level.txt +0 -0
  23. {better_python_doppler-2.0.0 → better_python_doppler-2.1.0}/tests/test_client.py +0 -0
  24. {better_python_doppler-2.0.0 → better_python_doppler-2.1.0}/tests/test_exceptions.py +0 -0
  25. {better_python_doppler-2.0.0 → better_python_doppler-2.1.0}/tests/test_secrets.py +0 -0
  26. {better_python_doppler-2.0.0 → better_python_doppler-2.1.0}/tests/test_secrets_client_api.py +0 -0
  27. {better_python_doppler-2.0.0 → better_python_doppler-2.1.0}/tests/test_transport.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: better-python-doppler
3
- Version: 2.0.0
3
+ Version: 2.1.0
4
4
  Summary: Lightweight, secrets-first Python SDK for the Doppler API.
5
5
  Author-email: Derek Banker <dbb2002@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -61,6 +61,7 @@ Supported and documented today:
61
61
  - direct token auth with `Doppler(service_token="...")`
62
62
  - explicit environment lookup with `Doppler(service_token_environ_name="...")`
63
63
  - explicit `.env` loading with `Doppler.from_env(...)`
64
+ - client-level scoped defaults with `client.set_scope(project_name, config_name)`
64
65
  - secrets access through `client.secrets`
65
66
  - compatibility access through `client.Secrets`
66
67
  - top-level compatibility exports: `Secrets` and `SecretsClient`
@@ -137,6 +138,25 @@ raw_value = client.secrets.get_raw("my-project", "dev", "API_KEY")
137
138
  print(raw_value)
138
139
  ```
139
140
 
141
+ ### Scoped Defaults
142
+
143
+ If you are repeatedly targeting the same project and config, set a client scope once and omit them from later secrets calls.
144
+
145
+ ```python
146
+ from better_python_doppler import Doppler
147
+
148
+ client = Doppler(service_token="dp.st.example-token").set_scope(
149
+ "my-project",
150
+ "dev",
151
+ )
152
+
153
+ print(client.secrets.list_names())
154
+ print(client.secrets.get_raw("API_KEY"))
155
+ client.secrets.set("API_KEY", "next-value")
156
+
157
+ client.clear_scope()
158
+ ```
159
+
140
160
  ### Single Secret Set/Get
141
161
 
142
162
  ```python
@@ -34,6 +34,7 @@ Supported and documented today:
34
34
  - direct token auth with `Doppler(service_token="...")`
35
35
  - explicit environment lookup with `Doppler(service_token_environ_name="...")`
36
36
  - explicit `.env` loading with `Doppler.from_env(...)`
37
+ - client-level scoped defaults with `client.set_scope(project_name, config_name)`
37
38
  - secrets access through `client.secrets`
38
39
  - compatibility access through `client.Secrets`
39
40
  - top-level compatibility exports: `Secrets` and `SecretsClient`
@@ -110,6 +111,25 @@ raw_value = client.secrets.get_raw("my-project", "dev", "API_KEY")
110
111
  print(raw_value)
111
112
  ```
112
113
 
114
+ ### Scoped Defaults
115
+
116
+ If you are repeatedly targeting the same project and config, set a client scope once and omit them from later secrets calls.
117
+
118
+ ```python
119
+ from better_python_doppler import Doppler
120
+
121
+ client = Doppler(service_token="dp.st.example-token").set_scope(
122
+ "my-project",
123
+ "dev",
124
+ )
125
+
126
+ print(client.secrets.list_names())
127
+ print(client.secrets.get_raw("API_KEY"))
128
+ client.secrets.set("API_KEY", "next-value")
129
+
130
+ client.clear_scope()
131
+ ```
132
+
113
133
  ### Single Secret Set/Get
114
134
 
115
135
  ```python
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "better-python-doppler"
7
- version = "2.0.0"
7
+ version = "2.1.0"
8
8
  dependencies = [
9
9
  "requests",
10
10
  "python-dotenv",
@@ -20,6 +20,8 @@ class Doppler:
20
20
  service_token_environ_name,
21
21
  )
22
22
  self._transport = RequestsTransport(self._service_token)
23
+ self._project_name: str | None = None
24
+ self._config_name: str | None = None
23
25
  self._secrets: SecretsClient | None = None
24
26
 
25
27
  @classmethod
@@ -67,12 +69,32 @@ class Doppler:
67
69
  def service_token(self) -> str:
68
70
  return self._service_token
69
71
 
72
+ def set_scope(self, project_name: str, config_name: str) -> "Doppler":
73
+ self._project_name = project_name
74
+ self._config_name = config_name
75
+
76
+ if self._secrets is not None:
77
+ self._secrets.set_scope(project_name, config_name)
78
+
79
+ return self
80
+
81
+ def clear_scope(self) -> "Doppler":
82
+ self._project_name = None
83
+ self._config_name = None
84
+
85
+ if self._secrets is not None:
86
+ self._secrets.clear_scope()
87
+
88
+ return self
89
+
70
90
  @property
71
91
  def secrets(self) -> SecretsClient:
72
92
  if self._secrets is None:
73
93
  self._secrets = SecretsClient(
74
94
  self._service_token,
75
95
  transport=self._transport,
96
+ project_name=self._project_name,
97
+ config_name=self._config_name,
76
98
  )
77
99
 
78
100
  return self._secrets
@@ -0,0 +1,606 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Literal
4
+
5
+ from requests import Response
6
+
7
+ from better_python_doppler.apis import SecretAPI
8
+ from better_python_doppler.exceptions import DopplerResponseError
9
+ from better_python_doppler.models import SecretModel, SecretValue
10
+ from better_python_doppler.transport import RequestsTransport, SyncTransport
11
+
12
+
13
+ DownloadFormat = Literal[
14
+ "json",
15
+ "dotnet-json",
16
+ "env",
17
+ "yaml",
18
+ "docker",
19
+ "env-no-quotes",
20
+ ]
21
+ NameTransformer = Literal[
22
+ "camel",
23
+ "upper-camel",
24
+ "lower-snake",
25
+ "tf-var",
26
+ "dotnet",
27
+ "dotnet-env",
28
+ "lower-kebab",
29
+ ]
30
+
31
+
32
+ class SecretsClient:
33
+ def __init__(
34
+ self,
35
+ service_token: str,
36
+ *,
37
+ transport: SyncTransport | None = None,
38
+ project_name: str | None = None,
39
+ config_name: str | None = None,
40
+ ) -> None:
41
+ self._service_token = service_token
42
+ self._transport = transport or RequestsTransport(service_token)
43
+ self._project_name = project_name
44
+ self._config_name = config_name
45
+
46
+ def set_scope(self, project_name: str, config_name: str) -> "SecretsClient":
47
+ self._project_name = project_name
48
+ self._config_name = config_name
49
+ return self
50
+
51
+ def clear_scope(self) -> "SecretsClient":
52
+ self._project_name = None
53
+ self._config_name = None
54
+ return self
55
+
56
+ def list(
57
+ self,
58
+ project_name: str | None = None,
59
+ config_name: str | None = None,
60
+ include_dynamic_secrets: bool = True,
61
+ dynamic_secrets_ttl_sec: int = 1800,
62
+ secrets: list[str] | None = None,
63
+ include_managed_secrets: bool = True,
64
+ ) -> list[SecretModel]:
65
+ project_name, config_name = self._resolve_project_and_config(
66
+ project_name,
67
+ config_name,
68
+ method_name="list",
69
+ )
70
+ response = SecretAPI.list_secrets(
71
+ transport=self._transport,
72
+ project_name=project_name,
73
+ config_name=config_name,
74
+ include_dynamic_secrets=include_dynamic_secrets,
75
+ dynamic_secrets_ttl_sec=dynamic_secrets_ttl_sec,
76
+ secrets=secrets,
77
+ include_managed_secrets=include_managed_secrets,
78
+ )
79
+ return response_to_models(response)
80
+
81
+ def list_names(
82
+ self,
83
+ project_name: str | None = None,
84
+ config_name: str | None = None,
85
+ include_dynamic_secrets: bool = False,
86
+ include_managed_secrets: bool = True,
87
+ ) -> list[str]:
88
+ project_name, config_name = self._resolve_project_and_config(
89
+ project_name,
90
+ config_name,
91
+ method_name="list_names",
92
+ )
93
+ response = SecretAPI.list_secret_names(
94
+ transport=self._transport,
95
+ project_name=project_name,
96
+ config_name=config_name,
97
+ include_dynamic_secrets=include_dynamic_secrets,
98
+ include_managed_secrets=include_managed_secrets,
99
+ )
100
+ data = _json_mapping(response, context="secret names")
101
+ names = data.get("names")
102
+ if names is None:
103
+ return []
104
+ if not isinstance(names, list) or not all(
105
+ isinstance(name, str) for name in names
106
+ ):
107
+ raise DopplerResponseError(
108
+ "Doppler secret names response contained an invalid `names` payload."
109
+ )
110
+ return names
111
+
112
+ def get(
113
+ self,
114
+ project_name: str | None = None,
115
+ config_name: str | None = None,
116
+ secret_name: str | None = None,
117
+ ) -> SecretModel:
118
+ project_name, config_name, secret_name = (
119
+ self._resolve_project_config_secret_name(
120
+ project_name,
121
+ config_name,
122
+ secret_name,
123
+ method_name="get",
124
+ )
125
+ )
126
+ response = SecretAPI.get_secret(
127
+ transport=self._transport,
128
+ project_name=project_name,
129
+ config_name=config_name,
130
+ secret_name=secret_name,
131
+ )
132
+ return response_to_model(response)
133
+
134
+ def get_raw(
135
+ self,
136
+ project_name: str | None = None,
137
+ config_name: str | None = None,
138
+ secret_name: str | None = None,
139
+ ) -> str | None:
140
+ project_name, config_name, secret_name = (
141
+ self._resolve_project_config_secret_name(
142
+ project_name,
143
+ config_name,
144
+ secret_name,
145
+ method_name="get_raw",
146
+ )
147
+ )
148
+ return self.get(project_name, config_name, secret_name).value.raw
149
+
150
+ def as_dict(
151
+ self,
152
+ project_name: str | None = None,
153
+ config_name: str | None = None,
154
+ include_dynamic_secrets: bool = True,
155
+ dynamic_secrets_ttl_sec: int = 1800,
156
+ secrets: list[str] | None = None,
157
+ include_managed_secrets: bool = True,
158
+ ) -> dict[str, str | None]:
159
+ project_name, config_name = self._resolve_project_and_config(
160
+ project_name,
161
+ config_name,
162
+ method_name="as_dict",
163
+ )
164
+ return {
165
+ secret.name: secret.value.raw
166
+ for secret in self.list(
167
+ project_name,
168
+ config_name,
169
+ include_dynamic_secrets=include_dynamic_secrets,
170
+ dynamic_secrets_ttl_sec=dynamic_secrets_ttl_sec,
171
+ secrets=secrets,
172
+ include_managed_secrets=include_managed_secrets,
173
+ )
174
+ if secret.name is not None
175
+ }
176
+
177
+ def set(
178
+ self,
179
+ project_name: str | None = None,
180
+ config_name: str | None = None,
181
+ secret_name: str | None = None,
182
+ secret_value: str | None = None,
183
+ ) -> SecretModel:
184
+ project_name, config_name, secret_name, secret_value = (
185
+ self._resolve_project_config_secret_value(
186
+ project_name,
187
+ config_name,
188
+ secret_name,
189
+ secret_value,
190
+ method_name="set",
191
+ )
192
+ )
193
+ return self.set_many(
194
+ project_name,
195
+ config_name,
196
+ {secret_name: secret_value},
197
+ )[0]
198
+
199
+ def set_many(
200
+ self,
201
+ project_name: str | dict[str, str] | None = None,
202
+ config_name: str | None = None,
203
+ secrets: dict[str, str] | None = None,
204
+ ) -> list[SecretModel]:
205
+ project_name, config_name, secrets = self._resolve_project_config_secrets(
206
+ project_name,
207
+ config_name,
208
+ secrets,
209
+ method_name="set_many",
210
+ )
211
+ response = SecretAPI.update_secrets(
212
+ transport=self._transport,
213
+ project_name=project_name,
214
+ config_name=config_name,
215
+ secrets=secrets,
216
+ )
217
+ return response_to_models(response)
218
+
219
+ def update(
220
+ self,
221
+ project_name: str | dict[str, str] | None = None,
222
+ config_name: str | None = None,
223
+ secret_name: str | None = None,
224
+ secret_value: str | None = None,
225
+ *,
226
+ secrets: dict[str, str] | None = None,
227
+ ) -> list[SecretModel]:
228
+ if (
229
+ isinstance(project_name, dict)
230
+ and config_name is None
231
+ and secret_name is None
232
+ and secret_value is None
233
+ and secrets is None
234
+ and self._project_name is not None
235
+ and self._config_name is not None
236
+ ):
237
+ secrets = project_name
238
+ project_name = None
239
+
240
+ project_name_input: str | None
241
+ if isinstance(project_name, dict):
242
+ project_name_input = None
243
+ else:
244
+ project_name_input = project_name
245
+
246
+ if (
247
+ isinstance(project_name_input, str)
248
+ and isinstance(config_name, str)
249
+ and secret_name is None
250
+ and secret_value is None
251
+ and secrets is None
252
+ and self._project_name is not None
253
+ and self._config_name is not None
254
+ ):
255
+ secret_name = project_name_input
256
+ secret_value = config_name
257
+ project_name_input = None
258
+ config_name = None
259
+
260
+ project_name, config_name = self._resolve_project_and_config(
261
+ project_name_input,
262
+ config_name,
263
+ method_name="update",
264
+ )
265
+
266
+ if secret_name is not None and secret_value is not None:
267
+ return self.set_many(
268
+ project_name,
269
+ config_name,
270
+ {secret_name: secret_value},
271
+ )
272
+
273
+ if secrets is not None:
274
+ return self.set_many(project_name, config_name, secrets)
275
+
276
+ raise ValueError(
277
+ "Invalid Parameter: Must provide `secret_name` and `secret_value` or `secrets`."
278
+ )
279
+
280
+ def download(
281
+ self,
282
+ project_name: str | None = None,
283
+ config_name: str | None = None,
284
+ format: DownloadFormat = "json",
285
+ name_transformer: NameTransformer | None = None,
286
+ include_dynamic_secrets: bool = False,
287
+ dynamic_secrets_ttl_sec: int = 1800,
288
+ secrets: list[str] | None = None,
289
+ ) -> dict[str, str] | str:
290
+ project_name, config_name = self._resolve_project_and_config(
291
+ project_name,
292
+ config_name,
293
+ method_name="download",
294
+ )
295
+ response = SecretAPI.download_secrets(
296
+ transport=self._transport,
297
+ project_name=project_name,
298
+ config_name=config_name,
299
+ format=format,
300
+ name_transformer=name_transformer,
301
+ include_dynamic_secrets=include_dynamic_secrets,
302
+ dynamic_secrets_ttl_sec=dynamic_secrets_ttl_sec,
303
+ secrets=secrets or [],
304
+ )
305
+ if format in ["json", "dotnet-json"]:
306
+ return _json_mapping(response, context="downloaded secrets")
307
+ return response.text
308
+
309
+ def delete(
310
+ self,
311
+ project_name: str | None = None,
312
+ config_name: str | None = None,
313
+ secret_name: str | None = None,
314
+ ) -> None:
315
+ project_name, config_name, secret_name = (
316
+ self._resolve_project_config_secret_name(
317
+ project_name,
318
+ config_name,
319
+ secret_name,
320
+ method_name="delete",
321
+ )
322
+ )
323
+ SecretAPI.delete_secret(
324
+ transport=self._transport,
325
+ project_name=project_name,
326
+ config_name=config_name,
327
+ secret_name=secret_name,
328
+ )
329
+
330
+ def update_note(
331
+ self,
332
+ project_name: str | None = None,
333
+ secret_name: str | None = None,
334
+ note: str | None = None,
335
+ ) -> dict[str, object]:
336
+ project_name, secret_name, note = self._resolve_project_secret_note(
337
+ project_name,
338
+ secret_name,
339
+ note,
340
+ method_name="update_note",
341
+ )
342
+ response = SecretAPI.update_note(
343
+ transport=self._transport,
344
+ project_name=project_name,
345
+ secret_name=secret_name,
346
+ note=note,
347
+ )
348
+ return _json_mapping(response, context="secret note update")
349
+
350
+ def _resolve_project_and_config(
351
+ self,
352
+ project_name: str | None,
353
+ config_name: str | None,
354
+ *,
355
+ method_name: str,
356
+ ) -> tuple[str, str]:
357
+ resolved_project_name = (
358
+ self._project_name if project_name is None else project_name
359
+ )
360
+ resolved_config_name = (
361
+ self._config_name if config_name is None else config_name
362
+ )
363
+
364
+ if resolved_project_name is None or resolved_config_name is None:
365
+ raise TypeError(
366
+ f"{method_name}() requires `project_name` and `config_name` unless a client scope is set."
367
+ )
368
+
369
+ return resolved_project_name, resolved_config_name
370
+
371
+ def _resolve_project(
372
+ self,
373
+ project_name: str | None,
374
+ *,
375
+ method_name: str,
376
+ ) -> str:
377
+ resolved_project_name = (
378
+ self._project_name if project_name is None else project_name
379
+ )
380
+
381
+ if resolved_project_name is None:
382
+ raise TypeError(
383
+ f"{method_name}() requires `project_name` unless a client scope is set."
384
+ )
385
+
386
+ return resolved_project_name
387
+
388
+ def _resolve_project_config_secret_name(
389
+ self,
390
+ project_name: str | None,
391
+ config_name: str | None,
392
+ secret_name: str | None,
393
+ *,
394
+ method_name: str,
395
+ ) -> tuple[str, str, str]:
396
+ if (
397
+ secret_name is None
398
+ and config_name is None
399
+ and project_name is not None
400
+ and self._project_name is not None
401
+ and self._config_name is not None
402
+ ):
403
+ secret_name = project_name
404
+ project_name = None
405
+
406
+ resolved_project_name, resolved_config_name = (
407
+ self._resolve_project_and_config(
408
+ project_name,
409
+ config_name,
410
+ method_name=method_name,
411
+ )
412
+ )
413
+
414
+ if secret_name is None:
415
+ raise TypeError(f"{method_name}() requires `secret_name`.")
416
+
417
+ return resolved_project_name, resolved_config_name, secret_name
418
+
419
+ def _resolve_project_config_secret_value(
420
+ self,
421
+ project_name: str | None,
422
+ config_name: str | None,
423
+ secret_name: str | None,
424
+ secret_value: str | None,
425
+ *,
426
+ method_name: str,
427
+ ) -> tuple[str, str, str, str]:
428
+ if (
429
+ secret_name is None
430
+ and secret_value is None
431
+ and project_name is not None
432
+ and config_name is not None
433
+ and self._project_name is not None
434
+ and self._config_name is not None
435
+ ):
436
+ secret_name = project_name
437
+ secret_value = config_name
438
+ project_name = None
439
+ config_name = None
440
+
441
+ resolved_project_name, resolved_config_name = (
442
+ self._resolve_project_and_config(
443
+ project_name,
444
+ config_name,
445
+ method_name=method_name,
446
+ )
447
+ )
448
+
449
+ if secret_name is None or secret_value is None:
450
+ raise TypeError(
451
+ f"{method_name}() requires `secret_name` and `secret_value`."
452
+ )
453
+
454
+ return (
455
+ resolved_project_name,
456
+ resolved_config_name,
457
+ secret_name,
458
+ secret_value,
459
+ )
460
+
461
+ def _resolve_project_config_secrets(
462
+ self,
463
+ project_name: str | dict[str, str] | None,
464
+ config_name: str | None,
465
+ secrets: dict[str, str] | None,
466
+ *,
467
+ method_name: str,
468
+ ) -> tuple[str, str, dict[str, str]]:
469
+ if (
470
+ isinstance(project_name, dict)
471
+ and config_name is None
472
+ and secrets is None
473
+ and self._project_name is not None
474
+ and self._config_name is not None
475
+ ):
476
+ secrets = project_name
477
+ project_name = None
478
+
479
+ project_name_input: str | None
480
+ if isinstance(project_name, dict):
481
+ project_name_input = None
482
+ else:
483
+ project_name_input = project_name
484
+
485
+ resolved_project_name, resolved_config_name = (
486
+ self._resolve_project_and_config(
487
+ project_name_input,
488
+ config_name,
489
+ method_name=method_name,
490
+ )
491
+ )
492
+
493
+ if secrets is None:
494
+ raise TypeError(f"{method_name}() requires `secrets`.")
495
+
496
+ return resolved_project_name, resolved_config_name, secrets
497
+
498
+ def _resolve_project_secret_note(
499
+ self,
500
+ project_name: str | None,
501
+ secret_name: str | None,
502
+ note: str | None,
503
+ *,
504
+ method_name: str,
505
+ ) -> tuple[str, str, str]:
506
+ if (
507
+ note is None
508
+ and project_name is not None
509
+ and secret_name is not None
510
+ and self._project_name is not None
511
+ ):
512
+ note = secret_name
513
+ secret_name = project_name
514
+ project_name = None
515
+
516
+ resolved_project_name = self._resolve_project(
517
+ project_name,
518
+ method_name=method_name,
519
+ )
520
+
521
+ if secret_name is None or note is None:
522
+ raise TypeError(
523
+ f"{method_name}() requires `secret_name` and `note`."
524
+ )
525
+
526
+ return resolved_project_name, secret_name, note
527
+
528
+
529
+ Secrets = SecretsClient
530
+
531
+
532
+ def response_to_model(response: Response) -> SecretModel:
533
+ data = _json_mapping(response, context="secret")
534
+ name = data.get("name")
535
+ return _secret_from_payload(
536
+ name=name if isinstance(name, str) else None,
537
+ value_payload=data.get("value"),
538
+ )
539
+
540
+
541
+ def response_to_models(response: Response) -> list[SecretModel]:
542
+ data = _json_mapping(response, context="secrets list")
543
+ secrets_payload = data.get("secrets")
544
+
545
+ if secrets_payload is None:
546
+ return []
547
+
548
+ if not isinstance(secrets_payload, dict):
549
+ raise DopplerResponseError(
550
+ "Doppler secrets list response contained an invalid `secrets` payload."
551
+ )
552
+
553
+ return [
554
+ _secret_from_payload(name=name, value_payload=value_payload)
555
+ for name, value_payload in secrets_payload.items()
556
+ ]
557
+
558
+
559
+ def _secret_from_payload(
560
+ name: str | None,
561
+ value_payload: object,
562
+ ) -> SecretModel:
563
+ value_dict = _secret_value_mapping(value_payload)
564
+ secret_value = SecretValue(
565
+ raw=value_dict.get("raw"),
566
+ computed=value_dict.get("computed"),
567
+ note=value_dict.get("note"),
568
+ )
569
+ return SecretModel(name=name, value=secret_value)
570
+
571
+
572
+ def _json_mapping(response: Response, *, context: str) -> dict[str, Any]:
573
+ try:
574
+ data = response.json()
575
+ except ValueError as exc:
576
+ raise DopplerResponseError(
577
+ f"Doppler returned invalid JSON for {context}."
578
+ ) from exc
579
+
580
+ if data is None:
581
+ return {}
582
+
583
+ if not isinstance(data, dict):
584
+ raise DopplerResponseError(
585
+ f"Doppler returned an unexpected JSON payload for {context}."
586
+ )
587
+
588
+ return data
589
+
590
+
591
+ def _secret_value_mapping(value_payload: object) -> dict[str, Any]:
592
+ if value_payload is None:
593
+ return {}
594
+
595
+ if not isinstance(value_payload, dict):
596
+ raise DopplerResponseError(
597
+ "Doppler secret response contained an invalid `value` payload."
598
+ )
599
+
600
+ return value_payload
601
+
602
+
603
+ __all__ = [
604
+ "Secrets",
605
+ "SecretsClient",
606
+ ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: better-python-doppler
3
- Version: 2.0.0
3
+ Version: 2.1.0
4
4
  Summary: Lightweight, secrets-first Python SDK for the Doppler API.
5
5
  Author-email: Derek Banker <dbb2002@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -61,6 +61,7 @@ Supported and documented today:
61
61
  - direct token auth with `Doppler(service_token="...")`
62
62
  - explicit environment lookup with `Doppler(service_token_environ_name="...")`
63
63
  - explicit `.env` loading with `Doppler.from_env(...)`
64
+ - client-level scoped defaults with `client.set_scope(project_name, config_name)`
64
65
  - secrets access through `client.secrets`
65
66
  - compatibility access through `client.Secrets`
66
67
  - top-level compatibility exports: `Secrets` and `SecretsClient`
@@ -137,6 +138,25 @@ raw_value = client.secrets.get_raw("my-project", "dev", "API_KEY")
137
138
  print(raw_value)
138
139
  ```
139
140
 
141
+ ### Scoped Defaults
142
+
143
+ If you are repeatedly targeting the same project and config, set a client scope once and omit them from later secrets calls.
144
+
145
+ ```python
146
+ from better_python_doppler import Doppler
147
+
148
+ client = Doppler(service_token="dp.st.example-token").set_scope(
149
+ "my-project",
150
+ "dev",
151
+ )
152
+
153
+ print(client.secrets.list_names())
154
+ print(client.secrets.get_raw("API_KEY"))
155
+ client.secrets.set("API_KEY", "next-value")
156
+
157
+ client.clear_scope()
158
+ ```
159
+
140
160
  ### Single Secret Set/Get
141
161
 
142
162
  ```python
@@ -1,306 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from typing import Any, Literal
4
-
5
- from requests import Response
6
-
7
- from better_python_doppler.apis import SecretAPI
8
- from better_python_doppler.exceptions import DopplerResponseError
9
- from better_python_doppler.models import SecretModel, SecretValue
10
- from better_python_doppler.transport import RequestsTransport, SyncTransport
11
-
12
-
13
- DownloadFormat = Literal[
14
- "json",
15
- "dotnet-json",
16
- "env",
17
- "yaml",
18
- "docker",
19
- "env-no-quotes",
20
- ]
21
- NameTransformer = Literal[
22
- "camel",
23
- "upper-camel",
24
- "lower-snake",
25
- "tf-var",
26
- "dotnet",
27
- "dotnet-env",
28
- "lower-kebab",
29
- ]
30
-
31
-
32
- class SecretsClient:
33
- def __init__(
34
- self,
35
- service_token: str,
36
- *,
37
- transport: SyncTransport | None = None,
38
- ) -> None:
39
- self._service_token = service_token
40
- self._transport = transport or RequestsTransport(service_token)
41
-
42
- def list(
43
- self,
44
- project_name: str,
45
- config_name: str,
46
- include_dynamic_secrets: bool = True,
47
- dynamic_secrets_ttl_sec: int = 1800,
48
- secrets: list[str] | None = None,
49
- include_managed_secrets: bool = True,
50
- ) -> list[SecretModel]:
51
- response = SecretAPI.list_secrets(
52
- transport=self._transport,
53
- project_name=project_name,
54
- config_name=config_name,
55
- include_dynamic_secrets=include_dynamic_secrets,
56
- dynamic_secrets_ttl_sec=dynamic_secrets_ttl_sec,
57
- secrets=secrets,
58
- include_managed_secrets=include_managed_secrets,
59
- )
60
- return response_to_models(response)
61
-
62
- def list_names(
63
- self,
64
- project_name: str,
65
- config_name: str,
66
- include_dynamic_secrets: bool = False,
67
- include_managed_secrets: bool = True,
68
- ) -> list[str]:
69
- response = SecretAPI.list_secret_names(
70
- transport=self._transport,
71
- project_name=project_name,
72
- config_name=config_name,
73
- include_dynamic_secrets=include_dynamic_secrets,
74
- include_managed_secrets=include_managed_secrets,
75
- )
76
- data = _json_mapping(response, context="secret names")
77
- names = data.get("names")
78
- if names is None:
79
- return []
80
- if not isinstance(names, list) or not all(
81
- isinstance(name, str) for name in names
82
- ):
83
- raise DopplerResponseError(
84
- "Doppler secret names response contained an invalid `names` payload."
85
- )
86
- return names
87
-
88
- def get(
89
- self,
90
- project_name: str,
91
- config_name: str,
92
- secret_name: str,
93
- ) -> SecretModel:
94
- response = SecretAPI.get_secret(
95
- transport=self._transport,
96
- project_name=project_name,
97
- config_name=config_name,
98
- secret_name=secret_name,
99
- )
100
- return response_to_model(response)
101
-
102
- def get_raw(
103
- self,
104
- project_name: str,
105
- config_name: str,
106
- secret_name: str,
107
- ) -> str | None:
108
- return self.get(project_name, config_name, secret_name).value.raw
109
-
110
- def as_dict(
111
- self,
112
- project_name: str,
113
- config_name: str,
114
- include_dynamic_secrets: bool = True,
115
- dynamic_secrets_ttl_sec: int = 1800,
116
- secrets: list[str] | None = None,
117
- include_managed_secrets: bool = True,
118
- ) -> dict[str, str | None]:
119
- return {
120
- secret.name: secret.value.raw
121
- for secret in self.list(
122
- project_name,
123
- config_name,
124
- include_dynamic_secrets=include_dynamic_secrets,
125
- dynamic_secrets_ttl_sec=dynamic_secrets_ttl_sec,
126
- secrets=secrets,
127
- include_managed_secrets=include_managed_secrets,
128
- )
129
- if secret.name is not None
130
- }
131
-
132
- def set(
133
- self,
134
- project_name: str,
135
- config_name: str,
136
- secret_name: str,
137
- secret_value: str,
138
- ) -> SecretModel:
139
- return self.set_many(
140
- project_name,
141
- config_name,
142
- {secret_name: secret_value},
143
- )[0]
144
-
145
- def set_many(
146
- self,
147
- project_name: str,
148
- config_name: str,
149
- secrets: dict[str, str],
150
- ) -> list[SecretModel]:
151
- response = SecretAPI.update_secrets(
152
- transport=self._transport,
153
- project_name=project_name,
154
- config_name=config_name,
155
- secrets=secrets,
156
- )
157
- return response_to_models(response)
158
-
159
- def update(
160
- self,
161
- project_name: str,
162
- config_name: str,
163
- secret_name: str | None = None,
164
- secret_value: str | None = None,
165
- *,
166
- secrets: dict[str, str] | None = None,
167
- ) -> list[SecretModel]:
168
- if secret_name is not None and secret_value is not None:
169
- return self.set_many(
170
- project_name,
171
- config_name,
172
- {secret_name: secret_value},
173
- )
174
-
175
- if secrets is not None:
176
- return self.set_many(project_name, config_name, secrets)
177
-
178
- raise ValueError(
179
- "Invalid Parameter: Must provide `secret_name` and `secret_value` or `secrets`."
180
- )
181
-
182
- def download(
183
- self,
184
- project_name: str,
185
- config_name: str,
186
- format: DownloadFormat = "json",
187
- name_transformer: NameTransformer | None = None,
188
- include_dynamic_secrets: bool = False,
189
- dynamic_secrets_ttl_sec: int = 1800,
190
- secrets: list[str] | None = None,
191
- ) -> dict[str, str] | str:
192
- response = SecretAPI.download_secrets(
193
- transport=self._transport,
194
- project_name=project_name,
195
- config_name=config_name,
196
- format=format,
197
- name_transformer=name_transformer,
198
- include_dynamic_secrets=include_dynamic_secrets,
199
- dynamic_secrets_ttl_sec=dynamic_secrets_ttl_sec,
200
- secrets=secrets or [],
201
- )
202
- if format in ["json", "dotnet-json"]:
203
- return _json_mapping(response, context="downloaded secrets")
204
- return response.text
205
-
206
- def delete(self, project_name: str, config_name: str, secret_name: str) -> None:
207
- SecretAPI.delete_secret(
208
- transport=self._transport,
209
- project_name=project_name,
210
- config_name=config_name,
211
- secret_name=secret_name,
212
- )
213
-
214
- def update_note(
215
- self,
216
- project_name: str,
217
- secret_name: str,
218
- note: str,
219
- ) -> dict[str, object]:
220
- response = SecretAPI.update_note(
221
- transport=self._transport,
222
- project_name=project_name,
223
- secret_name=secret_name,
224
- note=note,
225
- )
226
- return _json_mapping(response, context="secret note update")
227
-
228
-
229
- Secrets = SecretsClient
230
-
231
-
232
- def response_to_model(response: Response) -> SecretModel:
233
- data = _json_mapping(response, context="secret")
234
- name = data.get("name")
235
- return _secret_from_payload(
236
- name=name if isinstance(name, str) else None,
237
- value_payload=data.get("value"),
238
- )
239
-
240
-
241
- def response_to_models(response: Response) -> list[SecretModel]:
242
- data = _json_mapping(response, context="secrets list")
243
- secrets_payload = data.get("secrets")
244
-
245
- if secrets_payload is None:
246
- return []
247
-
248
- if not isinstance(secrets_payload, dict):
249
- raise DopplerResponseError(
250
- "Doppler secrets list response contained an invalid `secrets` payload."
251
- )
252
-
253
- return [
254
- _secret_from_payload(name=name, value_payload=value_payload)
255
- for name, value_payload in secrets_payload.items()
256
- ]
257
-
258
-
259
- def _secret_from_payload(
260
- name: str | None,
261
- value_payload: object,
262
- ) -> SecretModel:
263
- value_dict = _secret_value_mapping(value_payload)
264
- secret_value = SecretValue(
265
- raw=value_dict.get("raw"),
266
- computed=value_dict.get("computed"),
267
- note=value_dict.get("note"),
268
- )
269
- return SecretModel(name=name, value=secret_value)
270
-
271
-
272
- def _json_mapping(response: Response, *, context: str) -> dict[str, Any]:
273
- try:
274
- data = response.json()
275
- except ValueError as exc:
276
- raise DopplerResponseError(
277
- f"Doppler returned invalid JSON for {context}."
278
- ) from exc
279
-
280
- if data is None:
281
- return {}
282
-
283
- if not isinstance(data, dict):
284
- raise DopplerResponseError(
285
- f"Doppler returned an unexpected JSON payload for {context}."
286
- )
287
-
288
- return data
289
-
290
-
291
- def _secret_value_mapping(value_payload: object) -> dict[str, Any]:
292
- if value_payload is None:
293
- return {}
294
-
295
- if not isinstance(value_payload, dict):
296
- raise DopplerResponseError(
297
- "Doppler secret response contained an invalid `value` payload."
298
- )
299
-
300
- return value_payload
301
-
302
-
303
- __all__ = [
304
- "Secrets",
305
- "SecretsClient",
306
- ]