envbool 0.4.1__tar.gz → 0.5.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.
envbool-0.5.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kyle
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.
@@ -1,11 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: envbool
3
- Version: 0.4.1
3
+ Version: 0.5.0
4
4
  Summary: A small Python library and CLI tool for coercing environment variables (and arbitrary strings) into boolean values.
5
5
  Keywords: environment variables,boolean,configuration,env,coerce
6
6
  Author: Kyle O'Malley
7
7
  Author-email: Kyle O'Malley <j.kyle.omalley@gmail.com>
8
8
  License-Expression: MIT
9
+ License-File: LICENSE
9
10
  Classifier: Development Status :: 4 - Beta
10
11
  Classifier: Intended Audience :: Developers
11
12
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
@@ -44,9 +45,9 @@ Reading a boolean out of the environment is the kind of thing every project
44
45
  reinvents, slightly differently, in slightly buggy ways:
45
46
 
46
47
  ```python
47
- DEBUG = os.environ.get("DEBUG", "").lower() in ("1", "true", "yes")
48
+ DEBUG = os.environ.get("DEBUG", "").lower() in ("1", "true", "yes")
48
49
  VERBOSE = os.environ.get("VERBOSE", "").lower() in ("1", "true", "yes")
49
- CACHE = os.environ.get("CACHE", "").lower() in ("1", "true", "yes")
50
+ CACHE = os.environ.get("CACHE", "").lower() in ("1", "true", "yes")
50
51
  ```
51
52
 
52
53
  `envbool` is that snippet, done once and done properly:
@@ -54,9 +55,9 @@ CACHE = os.environ.get("CACHE", "").lower() in ("1", "true", "yes")
54
55
  ```python
55
56
  from envbool import envbool
56
57
 
57
- DEBUG = envbool("DEBUG")
58
+ DEBUG = envbool("DEBUG")
58
59
  VERBOSE = envbool("VERBOSE")
59
- CACHE = envbool("CACHE")
60
+ CACHE = envbool("CACHE")
60
61
  ```
61
62
 
62
63
  ## Features
@@ -90,6 +91,22 @@ pip install envbool
90
91
  uv add envbool
91
92
  ```
92
93
 
94
+ ### Snap
95
+
96
+ On Linux, the `envbool` command is also available as a strictly confined
97
+ [snap](https://snapcraft.io/):
98
+
99
+ ```bash
100
+ sudo snap install envbool
101
+ ```
102
+
103
+ The snap ships the CLI only; to `import envbool` from your own code, install it
104
+ with `pip` or `uv`. It reads ordinary environment variables unchanged, but
105
+ snapd sets a few variables for every snap, so the CLI sees snapd's values for
106
+ `HOME`, `PATH`, `TMPDIR`, `XDG_RUNTIME_DIR`, and `SNAP_*` rather than yours. If
107
+ both the snap and a pip install are present, whichever of `/snap/bin` or your
108
+ pip `bin` directory comes first on `PATH` wins.
109
+
93
110
  ## Usage
94
111
 
95
112
  ### The basics
@@ -100,8 +117,8 @@ uv add envbool
100
117
  ```python
101
118
  from envbool import envbool
102
119
 
103
- DEBUG = envbool("DEBUG") # False if unset or empty
104
- CACHE = envbool("CACHE", default=True) # True if unset or empty
120
+ DEBUG = envbool("DEBUG") # False if unset or empty
121
+ CACHE = envbool("CACHE", default=True) # True if unset or empty
105
122
  ```
106
123
 
107
124
  The built-in truthy values are `true`, `1`, `yes`, `on`; the falsy values are
@@ -112,6 +129,9 @@ surrounding whitespace.
112
129
 
113
130
  Pass `strict=True` to raise `InvalidBoolValueError` on anything outside the
114
131
  truthy/falsy sets — ideal for failing fast on a misconfigured deployment.
132
+ Strict mode also raises `ConflictingValuesError` if the effective truthy and
133
+ falsy sets overlap (e.g. `extend_falsy={"on"}`), on every call, whatever the
134
+ value. Lenient mode instead logs a warning and lets truthy win.
115
135
 
116
136
  ```python
117
137
  import sys
@@ -136,6 +156,16 @@ FEATURE = envbool("FEATURE_FLAG", extend_truthy={"enabled", "y"})
136
156
  LOCALE = envbool("USE_METRIC", truthy={"metric"}, falsy={"imperial"})
137
157
  ```
138
158
 
159
+ Each set is built in order: start from the base set (the built-ins, or
160
+ whatever `set_defaults()` configured), swap it out if `truthy`/`falsy` is
161
+ given, then add anything in `extend_truthy`/`extend_falsy`. Passing both
162
+ `truthy` and `extend_truthy` therefore gives you exactly their union:
163
+
164
+ ```python
165
+ to_bool("y", truthy={"yes"}, extend_truthy={"y"}) # True
166
+ to_bool("true", truthy={"yes"}, extend_truthy={"y"}) # False: built-ins replaced
167
+ ```
168
+
139
169
  ### Coercing arbitrary strings
140
170
 
141
171
  Use `to_bool` for values that don't come from the environment. It accepts the
@@ -144,8 +174,8 @@ same keyword arguments as `envbool`.
144
174
  ```python
145
175
  from envbool import to_bool
146
176
 
147
- to_bool("yes") # True
148
- to_bool("0") # False
177
+ to_bool("yes") # True
178
+ to_bool("0") # False
149
179
  to_bool("maybe", strict=True) # raises InvalidBoolValueError
150
180
  ```
151
181
 
@@ -176,6 +206,52 @@ built-in defaults → set_defaults() → function arguments / CLI flags
176
206
  `reset_defaults()` restores the built-ins — call it in a test fixture (see
177
207
  [Testing code that uses envbool](#testing-code-that-uses-envbool)).
178
208
 
209
+ ### Loading application settings
210
+
211
+ In a real application, read every flag once at startup into a single settings
212
+ object, with the policy set up front. With strict mode on, a typo like
213
+ `DEBUG=ture` stops startup instead of quietly reading as `False`:
214
+
215
+ ```python
216
+ import sys
217
+ from dataclasses import dataclass
218
+
219
+ import envbool
220
+ from envbool import EnvBoolError
221
+
222
+
223
+ @dataclass(frozen=True)
224
+ class Settings:
225
+ debug: bool
226
+ use_cache: bool
227
+ new_checkout: bool
228
+ send_emails: bool
229
+
230
+
231
+ def load_settings() -> Settings:
232
+ envbool.set_defaults(
233
+ strict=True,
234
+ extend_truthy=["enabled"],
235
+ extend_falsy=["disabled"],
236
+ )
237
+ return Settings(
238
+ debug=envbool.envbool("DEBUG"), # off unless set
239
+ use_cache=envbool.envbool("USE_CACHE", default=True), # on unless set
240
+ new_checkout=envbool.envbool("FEATURE_NEW_CHECKOUT"),
241
+ send_emails=envbool.envbool("SEND_EMAILS", required=True), # must be set
242
+ )
243
+
244
+
245
+ try:
246
+ SETTINGS = load_settings()
247
+ except EnvBoolError as e:
248
+ sys.exit(f"Invalid configuration: {e}")
249
+ ```
250
+
251
+ Catching `EnvBoolError` covers every failure: a bad value, a missing
252
+ `required` variable, or overlapping value sets. The rest of the application
253
+ reads `SETTINGS.debug` and never touches `os.environ` again.
254
+
179
255
  > Through 0.3.x, envbool read TOML config files (`envbool.toml`,
180
256
  > `[tool.envbool]`). 0.4.0 removed them in favor of `set_defaults()` — see
181
257
  > `CHANGELOG.md` for the rationale and migration note.
@@ -226,8 +302,10 @@ options:
226
302
  --truthy VALUE Replace the truthy set with VALUE (repeatable).
227
303
  --falsy VALUE Replace the falsy set with VALUE (repeatable).
228
304
  --extend-truthy VALUE
229
- Add VALUE to the truthy set (repeatable).
230
- --extend-falsy VALUE Add VALUE to the falsy set (repeatable).
305
+ Add VALUE to the truthy set, after any --truthy
306
+ (repeatable).
307
+ --extend-falsy VALUE Add VALUE to the falsy set, after any --falsy
308
+ (repeatable).
231
309
  ```
232
310
 
233
311
  A few rules worth knowing:
@@ -241,6 +319,29 @@ A few rules worth knowing:
241
319
  - With no `VAR_NAME`, `--value`, or non-empty piped stdin, the CLI prints
242
320
  usage and exits `2`.
243
321
 
322
+ ### Scripts using `set -e`
323
+
324
+ Under `set -e` (errexit), a falsy result is a failing command: a bare
325
+ `envbool FLAG` on its own line aborts the script when the flag is off. Check
326
+ the status inside a condition instead, where errexit doesn't apply, or use
327
+ `--print` to get the answer as text:
328
+
329
+ ```bash
330
+ set -e
331
+
332
+ envbool FLAG # aborts the script when FLAG is falsy
333
+
334
+ if envbool FLAG; then # safe: the status is the condition
335
+ echo "on"
336
+ fi
337
+
338
+ flag=$(envbool --print FLAG) # safe: always exits 0 unless there's an error
339
+ ```
340
+
341
+ `--print` still exits `2` on an error, so `set -e` catches a missing
342
+ `--required` variable or a bad value under `--strict`, while a falsy value
343
+ doesn't stop the script.
344
+
244
345
  ## API reference
245
346
 
246
347
  | Symbol | Description |
@@ -255,6 +356,7 @@ A few rules worth knowing:
255
356
  | `DEFAULT_FALSY` | `frozenset` of the built-in falsy strings. |
256
357
  | `EnvBoolError` | Base class for every exception the library raises. |
257
358
  | `InvalidBoolValueError` | Raised in strict mode for unrecognized values. Also a `ValueError`. |
359
+ | `ConflictingValuesError` | Raised in strict mode when the truthy and falsy sets overlap. Also a `ValueError`. |
258
360
  | `MissingEnvVarError` | Raised by `envbool(required=True)` when the variable is unset. Also a `KeyError`. |
259
361
 
260
362
  `envbool()` and `to_bool()` share the same keyword-only options:
@@ -265,7 +367,7 @@ A few rules worth knowing:
265
367
  | `strict` | `bool \| None` | `None` | Raise on unrecognized values (`None` defers to `set_defaults()`). |
266
368
  | `warn` | `bool \| None` | `None` | Log a warning on unrecognized values (`None` defers to `set_defaults()`). |
267
369
  | `truthy` / `falsy` | `Iterable[str] \| None` | `None` | **Replace** the effective set. |
268
- | `extend_truthy` / `extend_falsy` | `Iterable[str] \| None` | `None` | **Extend** the effective set. |
370
+ | `extend_truthy` / `extend_falsy` | `Iterable[str] \| None` | `None` | **Extend** the effective set, after any replacement. |
269
371
 
270
372
  `envbool()` also accepts `required` (`bool`, default `False`): when `True`, a
271
373
  variable that is unset raises `MissingEnvVarError` before `default` is applied. A
@@ -284,9 +386,9 @@ from envbool import envbool, InvalidBoolValueError
284
386
  try:
285
387
  result = envbool("MY_VAR", strict=True)
286
388
  except InvalidBoolValueError as e:
287
- print(e.var) # "MY_VAR" — env var name, or None when raised from to_bool()
389
+ print(e.var) # "MY_VAR" — env var name, or None when raised from to_bool()
288
390
  print(e.value) # "maybe" — the normalized (stripped, lowercased) value
289
- print(e.truthy) # frozenset({"true", "1", "yes", "on"}) — effective truthy set
391
+ print(e.truthy) # frozenset({"true", "1", "yes", "on"}) — effective truthy set
290
392
  print(e.falsy) # frozenset({"false", "0", "no", "off"}) — effective falsy set
291
393
  ```
292
394
 
@@ -316,7 +418,7 @@ logging.getLogger("envbool").addHandler(logging.StreamHandler())
316
418
  | Level | When |
317
419
  | --- | --- |
318
420
  | `WARNING` | An unrecognized value fell through in lenient mode (only when `warn=True`). |
319
- | `WARNING` | The truthy and falsy sets overlap (truthy wins). |
421
+ | `WARNING` | The truthy and falsy sets overlap in lenient mode (truthy wins; strict mode raises `ConflictingValuesError` instead). |
320
422
 
321
423
  ### The unset-vs-empty distinction
322
424
 
@@ -345,6 +447,7 @@ fixture so overrides don't leak across the suite:
345
447
  import pytest
346
448
  from envbool import reset_defaults
347
449
 
450
+
348
451
  @pytest.fixture(autouse=True)
349
452
  def _reset_envbool_defaults():
350
453
  yield
@@ -17,9 +17,9 @@ Reading a boolean out of the environment is the kind of thing every project
17
17
  reinvents, slightly differently, in slightly buggy ways:
18
18
 
19
19
  ```python
20
- DEBUG = os.environ.get("DEBUG", "").lower() in ("1", "true", "yes")
20
+ DEBUG = os.environ.get("DEBUG", "").lower() in ("1", "true", "yes")
21
21
  VERBOSE = os.environ.get("VERBOSE", "").lower() in ("1", "true", "yes")
22
- CACHE = os.environ.get("CACHE", "").lower() in ("1", "true", "yes")
22
+ CACHE = os.environ.get("CACHE", "").lower() in ("1", "true", "yes")
23
23
  ```
24
24
 
25
25
  `envbool` is that snippet, done once and done properly:
@@ -27,9 +27,9 @@ CACHE = os.environ.get("CACHE", "").lower() in ("1", "true", "yes")
27
27
  ```python
28
28
  from envbool import envbool
29
29
 
30
- DEBUG = envbool("DEBUG")
30
+ DEBUG = envbool("DEBUG")
31
31
  VERBOSE = envbool("VERBOSE")
32
- CACHE = envbool("CACHE")
32
+ CACHE = envbool("CACHE")
33
33
  ```
34
34
 
35
35
  ## Features
@@ -63,6 +63,22 @@ pip install envbool
63
63
  uv add envbool
64
64
  ```
65
65
 
66
+ ### Snap
67
+
68
+ On Linux, the `envbool` command is also available as a strictly confined
69
+ [snap](https://snapcraft.io/):
70
+
71
+ ```bash
72
+ sudo snap install envbool
73
+ ```
74
+
75
+ The snap ships the CLI only; to `import envbool` from your own code, install it
76
+ with `pip` or `uv`. It reads ordinary environment variables unchanged, but
77
+ snapd sets a few variables for every snap, so the CLI sees snapd's values for
78
+ `HOME`, `PATH`, `TMPDIR`, `XDG_RUNTIME_DIR`, and `SNAP_*` rather than yours. If
79
+ both the snap and a pip install are present, whichever of `/snap/bin` or your
80
+ pip `bin` directory comes first on `PATH` wins.
81
+
66
82
  ## Usage
67
83
 
68
84
  ### The basics
@@ -73,8 +89,8 @@ uv add envbool
73
89
  ```python
74
90
  from envbool import envbool
75
91
 
76
- DEBUG = envbool("DEBUG") # False if unset or empty
77
- CACHE = envbool("CACHE", default=True) # True if unset or empty
92
+ DEBUG = envbool("DEBUG") # False if unset or empty
93
+ CACHE = envbool("CACHE", default=True) # True if unset or empty
78
94
  ```
79
95
 
80
96
  The built-in truthy values are `true`, `1`, `yes`, `on`; the falsy values are
@@ -85,6 +101,9 @@ surrounding whitespace.
85
101
 
86
102
  Pass `strict=True` to raise `InvalidBoolValueError` on anything outside the
87
103
  truthy/falsy sets — ideal for failing fast on a misconfigured deployment.
104
+ Strict mode also raises `ConflictingValuesError` if the effective truthy and
105
+ falsy sets overlap (e.g. `extend_falsy={"on"}`), on every call, whatever the
106
+ value. Lenient mode instead logs a warning and lets truthy win.
88
107
 
89
108
  ```python
90
109
  import sys
@@ -109,6 +128,16 @@ FEATURE = envbool("FEATURE_FLAG", extend_truthy={"enabled", "y"})
109
128
  LOCALE = envbool("USE_METRIC", truthy={"metric"}, falsy={"imperial"})
110
129
  ```
111
130
 
131
+ Each set is built in order: start from the base set (the built-ins, or
132
+ whatever `set_defaults()` configured), swap it out if `truthy`/`falsy` is
133
+ given, then add anything in `extend_truthy`/`extend_falsy`. Passing both
134
+ `truthy` and `extend_truthy` therefore gives you exactly their union:
135
+
136
+ ```python
137
+ to_bool("y", truthy={"yes"}, extend_truthy={"y"}) # True
138
+ to_bool("true", truthy={"yes"}, extend_truthy={"y"}) # False: built-ins replaced
139
+ ```
140
+
112
141
  ### Coercing arbitrary strings
113
142
 
114
143
  Use `to_bool` for values that don't come from the environment. It accepts the
@@ -117,8 +146,8 @@ same keyword arguments as `envbool`.
117
146
  ```python
118
147
  from envbool import to_bool
119
148
 
120
- to_bool("yes") # True
121
- to_bool("0") # False
149
+ to_bool("yes") # True
150
+ to_bool("0") # False
122
151
  to_bool("maybe", strict=True) # raises InvalidBoolValueError
123
152
  ```
124
153
 
@@ -149,6 +178,52 @@ built-in defaults → set_defaults() → function arguments / CLI flags
149
178
  `reset_defaults()` restores the built-ins — call it in a test fixture (see
150
179
  [Testing code that uses envbool](#testing-code-that-uses-envbool)).
151
180
 
181
+ ### Loading application settings
182
+
183
+ In a real application, read every flag once at startup into a single settings
184
+ object, with the policy set up front. With strict mode on, a typo like
185
+ `DEBUG=ture` stops startup instead of quietly reading as `False`:
186
+
187
+ ```python
188
+ import sys
189
+ from dataclasses import dataclass
190
+
191
+ import envbool
192
+ from envbool import EnvBoolError
193
+
194
+
195
+ @dataclass(frozen=True)
196
+ class Settings:
197
+ debug: bool
198
+ use_cache: bool
199
+ new_checkout: bool
200
+ send_emails: bool
201
+
202
+
203
+ def load_settings() -> Settings:
204
+ envbool.set_defaults(
205
+ strict=True,
206
+ extend_truthy=["enabled"],
207
+ extend_falsy=["disabled"],
208
+ )
209
+ return Settings(
210
+ debug=envbool.envbool("DEBUG"), # off unless set
211
+ use_cache=envbool.envbool("USE_CACHE", default=True), # on unless set
212
+ new_checkout=envbool.envbool("FEATURE_NEW_CHECKOUT"),
213
+ send_emails=envbool.envbool("SEND_EMAILS", required=True), # must be set
214
+ )
215
+
216
+
217
+ try:
218
+ SETTINGS = load_settings()
219
+ except EnvBoolError as e:
220
+ sys.exit(f"Invalid configuration: {e}")
221
+ ```
222
+
223
+ Catching `EnvBoolError` covers every failure: a bad value, a missing
224
+ `required` variable, or overlapping value sets. The rest of the application
225
+ reads `SETTINGS.debug` and never touches `os.environ` again.
226
+
152
227
  > Through 0.3.x, envbool read TOML config files (`envbool.toml`,
153
228
  > `[tool.envbool]`). 0.4.0 removed them in favor of `set_defaults()` — see
154
229
  > `CHANGELOG.md` for the rationale and migration note.
@@ -199,8 +274,10 @@ options:
199
274
  --truthy VALUE Replace the truthy set with VALUE (repeatable).
200
275
  --falsy VALUE Replace the falsy set with VALUE (repeatable).
201
276
  --extend-truthy VALUE
202
- Add VALUE to the truthy set (repeatable).
203
- --extend-falsy VALUE Add VALUE to the falsy set (repeatable).
277
+ Add VALUE to the truthy set, after any --truthy
278
+ (repeatable).
279
+ --extend-falsy VALUE Add VALUE to the falsy set, after any --falsy
280
+ (repeatable).
204
281
  ```
205
282
 
206
283
  A few rules worth knowing:
@@ -214,6 +291,29 @@ A few rules worth knowing:
214
291
  - With no `VAR_NAME`, `--value`, or non-empty piped stdin, the CLI prints
215
292
  usage and exits `2`.
216
293
 
294
+ ### Scripts using `set -e`
295
+
296
+ Under `set -e` (errexit), a falsy result is a failing command: a bare
297
+ `envbool FLAG` on its own line aborts the script when the flag is off. Check
298
+ the status inside a condition instead, where errexit doesn't apply, or use
299
+ `--print` to get the answer as text:
300
+
301
+ ```bash
302
+ set -e
303
+
304
+ envbool FLAG # aborts the script when FLAG is falsy
305
+
306
+ if envbool FLAG; then # safe: the status is the condition
307
+ echo "on"
308
+ fi
309
+
310
+ flag=$(envbool --print FLAG) # safe: always exits 0 unless there's an error
311
+ ```
312
+
313
+ `--print` still exits `2` on an error, so `set -e` catches a missing
314
+ `--required` variable or a bad value under `--strict`, while a falsy value
315
+ doesn't stop the script.
316
+
217
317
  ## API reference
218
318
 
219
319
  | Symbol | Description |
@@ -228,6 +328,7 @@ A few rules worth knowing:
228
328
  | `DEFAULT_FALSY` | `frozenset` of the built-in falsy strings. |
229
329
  | `EnvBoolError` | Base class for every exception the library raises. |
230
330
  | `InvalidBoolValueError` | Raised in strict mode for unrecognized values. Also a `ValueError`. |
331
+ | `ConflictingValuesError` | Raised in strict mode when the truthy and falsy sets overlap. Also a `ValueError`. |
231
332
  | `MissingEnvVarError` | Raised by `envbool(required=True)` when the variable is unset. Also a `KeyError`. |
232
333
 
233
334
  `envbool()` and `to_bool()` share the same keyword-only options:
@@ -238,7 +339,7 @@ A few rules worth knowing:
238
339
  | `strict` | `bool \| None` | `None` | Raise on unrecognized values (`None` defers to `set_defaults()`). |
239
340
  | `warn` | `bool \| None` | `None` | Log a warning on unrecognized values (`None` defers to `set_defaults()`). |
240
341
  | `truthy` / `falsy` | `Iterable[str] \| None` | `None` | **Replace** the effective set. |
241
- | `extend_truthy` / `extend_falsy` | `Iterable[str] \| None` | `None` | **Extend** the effective set. |
342
+ | `extend_truthy` / `extend_falsy` | `Iterable[str] \| None` | `None` | **Extend** the effective set, after any replacement. |
242
343
 
243
344
  `envbool()` also accepts `required` (`bool`, default `False`): when `True`, a
244
345
  variable that is unset raises `MissingEnvVarError` before `default` is applied. A
@@ -257,9 +358,9 @@ from envbool import envbool, InvalidBoolValueError
257
358
  try:
258
359
  result = envbool("MY_VAR", strict=True)
259
360
  except InvalidBoolValueError as e:
260
- print(e.var) # "MY_VAR" — env var name, or None when raised from to_bool()
361
+ print(e.var) # "MY_VAR" — env var name, or None when raised from to_bool()
261
362
  print(e.value) # "maybe" — the normalized (stripped, lowercased) value
262
- print(e.truthy) # frozenset({"true", "1", "yes", "on"}) — effective truthy set
363
+ print(e.truthy) # frozenset({"true", "1", "yes", "on"}) — effective truthy set
263
364
  print(e.falsy) # frozenset({"false", "0", "no", "off"}) — effective falsy set
264
365
  ```
265
366
 
@@ -289,7 +390,7 @@ logging.getLogger("envbool").addHandler(logging.StreamHandler())
289
390
  | Level | When |
290
391
  | --- | --- |
291
392
  | `WARNING` | An unrecognized value fell through in lenient mode (only when `warn=True`). |
292
- | `WARNING` | The truthy and falsy sets overlap (truthy wins). |
393
+ | `WARNING` | The truthy and falsy sets overlap in lenient mode (truthy wins; strict mode raises `ConflictingValuesError` instead). |
293
394
 
294
395
  ### The unset-vs-empty distinction
295
396
 
@@ -318,6 +419,7 @@ fixture so overrides don't leak across the suite:
318
419
  import pytest
319
420
  from envbool import reset_defaults
320
421
 
422
+
321
423
  @pytest.fixture(autouse=True)
322
424
  def _reset_envbool_defaults():
323
425
  yield
@@ -1,10 +1,11 @@
1
1
  [project]
2
2
  name = "envbool"
3
- version = "0.4.1"
3
+ version = "0.5.0"
4
4
  description = "A small Python library and CLI tool for coercing environment variables (and arbitrary strings) into boolean values."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
7
7
  license = "MIT"
8
+ license-files = ["LICENSE"]
8
9
  keywords = [
9
10
  "environment variables",
10
11
  "boolean",
@@ -1,11 +1,12 @@
1
1
  [project]
2
2
  name = "envbool"
3
- version = "0.4.1"
3
+ version = "0.5.0"
4
4
  description = "A small Python library and CLI tool for coercing environment variables (and arbitrary strings) into boolean values."
5
5
  readme = "README.md"
6
6
  authors = [{ name = "Kyle O'Malley", email = "j.kyle.omalley@gmail.com" }]
7
7
  requires-python = ">=3.11"
8
8
  license = "MIT"
9
+ license-files = ["LICENSE"]
9
10
  keywords = ["environment variables", "boolean", "configuration", "env", "coerce"]
10
11
  dependencies = []
11
12
  classifiers = [
@@ -9,17 +9,18 @@ For except clauses, envbool.exceptions is also importable by name:
9
9
  from envbool.exceptions import InvalidBoolValueError
10
10
 
11
11
  Available names:
12
- envbool() -- read an env var and coerce to bool (primary API)
13
- to_bool() -- coerce an arbitrary string to bool (no os.environ)
14
- set_defaults() -- set process-level strict/warn/truthy/falsy defaults
15
- get_defaults() -- inspect the active process-level Defaults
16
- reset_defaults() -- restore built-in defaults (for test fixtures)
17
- Defaults -- frozen dataclass returned by get_defaults()
18
- DEFAULT_TRUTHY -- built-in truthy set (frozenset)
19
- DEFAULT_FALSY -- built-in falsy set (frozenset)
20
- EnvBoolError -- base exception for all envbool errors
21
- InvalidBoolValueError -- raised in strict mode for unrecognized values
22
- MissingEnvVarError -- raised by envbool(required=True) when a var is unset
12
+ envbool() -- read an env var and coerce to bool (primary API)
13
+ to_bool() -- coerce an arbitrary string to bool (no os.environ)
14
+ set_defaults() -- set process-level strict/warn/truthy/falsy defaults
15
+ get_defaults() -- inspect the active process-level Defaults
16
+ reset_defaults() -- restore built-in defaults (for test fixtures)
17
+ Defaults -- frozen dataclass returned by get_defaults()
18
+ DEFAULT_TRUTHY -- built-in truthy set (frozenset)
19
+ DEFAULT_FALSY -- built-in falsy set (frozenset)
20
+ EnvBoolError -- base exception for all envbool errors
21
+ InvalidBoolValueError -- raised in strict mode for unrecognized values
22
+ ConflictingValuesError -- raised in strict mode when truthy/falsy overlap
23
+ MissingEnvVarError -- raised by envbool(required=True) when a var is unset
23
24
  """
24
25
  # All implementation lives in private underscore-prefixed modules so the public
25
26
  # surface can be reshaped without breaking imports. Do not import from _core,
@@ -36,6 +37,7 @@ from envbool._defaults import (
36
37
  )
37
38
  from envbool._env import envbool
38
39
  from envbool.exceptions import (
40
+ ConflictingValuesError,
39
41
  EnvBoolError,
40
42
  InvalidBoolValueError,
41
43
  MissingEnvVarError,
@@ -44,6 +46,7 @@ from envbool.exceptions import (
44
46
  __all__ = [
45
47
  "DEFAULT_FALSY",
46
48
  "DEFAULT_TRUTHY",
49
+ "ConflictingValuesError",
47
50
  "Defaults",
48
51
  "EnvBoolError",
49
52
  "InvalidBoolValueError",
@@ -18,8 +18,8 @@ Omitting --strict or --warn defers to the process-level defaults
18
18
  (envbool.set_defaults(); default: lenient, no warnings).
19
19
 
20
20
  Value sets: --truthy/--falsy (repeatable) replace the truthy/falsy set;
21
- --extend-truthy/--extend-falsy (repeatable) add to it. Mirrors ruff's
22
- select/extend-select pattern.
21
+ --extend-truthy/--extend-falsy (repeatable) add to it, after any
22
+ replacement.
23
23
 
24
24
  Public surface:
25
25
  main() -- entry point registered as the "envbool" command
@@ -36,7 +36,7 @@ import sys
36
36
 
37
37
  from envbool._core import to_bool
38
38
  from envbool._env import envbool
39
- from envbool.exceptions import InvalidBoolValueError, MissingEnvVarError
39
+ from envbool.exceptions import EnvBoolError
40
40
 
41
41
 
42
42
  def _build_parser() -> argparse.ArgumentParser:
@@ -114,13 +114,13 @@ def _build_parser() -> argparse.ArgumentParser:
114
114
  "--extend-truthy",
115
115
  metavar="VALUE",
116
116
  action="append",
117
- help="Add VALUE to the truthy set (repeatable).",
117
+ help="Add VALUE to the truthy set, after any --truthy (repeatable).",
118
118
  )
119
119
  parser.add_argument(
120
120
  "--extend-falsy",
121
121
  metavar="VALUE",
122
122
  action="append",
123
- help="Add VALUE to the falsy set (repeatable).",
123
+ help="Add VALUE to the falsy set, after any --falsy (repeatable).",
124
124
  )
125
125
  return parser
126
126
 
@@ -213,7 +213,7 @@ def main() -> None:
213
213
  # SystemExit, which is not caught here and so propagates as intended.
214
214
  try:
215
215
  result = _coerce_from_source(parser, args)
216
- except (InvalidBoolValueError, MissingEnvVarError) as e:
216
+ except EnvBoolError as e:
217
217
  print(f"error: {e}", file=sys.stderr)
218
218
  sys.exit(2)
219
219
 
@@ -21,10 +21,10 @@ from collections.abc import Iterable
21
21
  from envbool._defaults import (
22
22
  DEFAULT_FALSY,
23
23
  DEFAULT_TRUTHY,
24
- _apply_replace_or_extend,
24
+ _apply_replace_then_extend,
25
25
  get_defaults,
26
26
  )
27
- from envbool.exceptions import InvalidBoolValueError
27
+ from envbool.exceptions import ConflictingValuesError, InvalidBoolValueError
28
28
 
29
29
  # Module-level logger -- attributed to "envbool._core" so callers can filter it
30
30
  # independently from "envbool.config" or the root "envbool" logger.
@@ -56,8 +56,10 @@ def to_bool(
56
56
  process-level defaults (set_defaults()) (default False).
57
57
  truthy: Replaces the effective truthy set.
58
58
  falsy: Replaces the effective falsy set.
59
- extend_truthy: Extends the effective truthy set.
60
- extend_falsy: Extends the effective falsy set.
59
+ extend_truthy: Extends the effective truthy set, after any truthy
60
+ replacement.
61
+ extend_falsy: Extends the effective falsy set, after any falsy
62
+ replacement.
61
63
  _var: Internal - env var name for error messages when called via envbool().
62
64
 
63
65
  Returns:
@@ -65,6 +67,8 @@ def to_bool(
65
67
 
66
68
  Raises:
67
69
  InvalidBoolValueError: In strict mode when value is unrecognized.
70
+ ConflictingValuesError: In strict mode when the effective truthy and
71
+ falsy sets overlap.
68
72
  """
69
73
  # Normalize first so all comparisons are case- and whitespace-insensitive.
70
74
  # Empty after normalization means "unset" -- return the caller's default
@@ -90,10 +94,27 @@ def to_bool(
90
94
  extend_falsy=extend_falsy,
91
95
  )
92
96
 
93
- # Overlapping sets are a caller mistake, not a runtime error. Warn so the
94
- # problem is visible, then let truthy win to stay consistent and predictable.
97
+ # Three-state logic: True/False at the call site override the process-level
98
+ # default; None defers to whatever set_defaults() last set (which defaults
99
+ # to False if set_defaults() was never called). Resolved before the lookup
100
+ # because the overlap check below also depends on it.
101
+ effective_strict = strict if strict is not None else defaults.strict
102
+
103
+ # Overlapping sets are a configuration mistake. Strict mode promises every
104
+ # accepted value is unambiguous, so it rejects the configuration outright --
105
+ # on every call, not just when the value lands in the overlap, so the
106
+ # mistake surfaces at the first strict read. Lenient mode warns so the
107
+ # problem is visible, then lets truthy win to stay predictable.
95
108
  overlap = effective_truthy & effective_falsy
96
109
  if overlap:
110
+ if effective_strict:
111
+ err = ConflictingValuesError(
112
+ f"Truthy and falsy sets overlap: {', '.join(sorted(overlap))}"
113
+ )
114
+ err.overlap = overlap
115
+ err.truthy = effective_truthy
116
+ err.falsy = effective_falsy
117
+ raise err
97
118
  _logger.warning(
98
119
  "Overlapping truthy/falsy values (truthy wins): %s", sorted(overlap)
99
120
  )
@@ -101,15 +122,11 @@ def to_bool(
101
122
  if normalized in effective_truthy:
102
123
  return True
103
124
 
104
- # Falsy is checked after truthy so the overlap rule above is enforced
105
- # without any extra branching.
125
+ # Falsy is checked after truthy so the lenient overlap rule above (truthy
126
+ # wins) is enforced without any extra branching.
106
127
  if normalized in effective_falsy:
107
128
  return False
108
129
 
109
- # Three-state logic: True/False at the call site override the process-level
110
- # default; None defers to whatever set_defaults() last set (which defaults
111
- # to False if set_defaults() was never called).
112
- effective_strict = strict if strict is not None else defaults.strict
113
130
  if effective_strict:
114
131
  truthy_list = ", ".join(sorted(effective_truthy))
115
132
  falsy_list = ", ".join(sorted(effective_falsy))
@@ -155,9 +172,9 @@ def _resolve(
155
172
  extend_truthy: Iterable[str] | None = None,
156
173
  extend_falsy: Iterable[str] | None = None,
157
174
  ) -> tuple[frozenset[str], frozenset[str]]:
158
- # Priority mirrors ruff's select/extend-select pattern -- see
159
- # _apply_replace_or_extend() docstring for the full precedence rules.
160
- effective_truthy = _apply_replace_or_extend(config_truthy, truthy, extend_truthy)
161
- effective_falsy = _apply_replace_or_extend(config_falsy, falsy, extend_falsy)
175
+ # Replace-then-extend, per set -- see the _apply_replace_then_extend()
176
+ # docstring for the full precedence rules.
177
+ effective_truthy = _apply_replace_then_extend(config_truthy, truthy, extend_truthy)
178
+ effective_falsy = _apply_replace_then_extend(config_falsy, falsy, extend_falsy)
162
179
 
163
180
  return (effective_truthy, effective_falsy)
@@ -37,34 +37,34 @@ def _normalize_set(values: Iterable[str]) -> frozenset[str]:
37
37
  return frozenset(v.strip().lower() for v in values)
38
38
 
39
39
 
40
- def _apply_replace_or_extend(
40
+ def _apply_replace_then_extend(
41
41
  base: frozenset[str],
42
42
  replace: Iterable[str] | None,
43
43
  extend: Iterable[str] | None,
44
44
  ) -> frozenset[str]:
45
- """Resolve a value set using replace/extend/fall-back-to-base precedence.
45
+ """Resolve a value set: replace the base (if given), then extend the result.
46
46
 
47
47
  Shared by _resolve() (call-site truthy/falsy args, in _core.py) and
48
48
  set_defaults() (below) since both layer their inputs on top of a base set
49
- using the same ruff select/extend-select pattern:
50
- replace -- full replacement; caller owns the entire set
51
- extend -- additive; merges on top of base
49
+ the same way:
50
+ replace -- swaps out base entirely; the caller owns the starting set
51
+ extend -- additive; merged on top of whatever replace left
52
52
  neither -- use base as-is
53
- replace takes precedence over extend; both cannot apply at once.
53
+ Passing both applies replace first, then extend, so neither argument is
54
+ silently dropped.
54
55
 
55
56
  Args:
56
- base: The starting set to fall back to or extend.
57
+ base: The starting set, used when replace is None.
57
58
  replace: If not None, fully replaces base (normalized).
58
- extend: If not None and replace is None, merged on top of base.
59
+ extend: If not None, merged on top of the (possibly replaced) set.
59
60
 
60
61
  Returns:
61
62
  The resolved, normalized frozenset.
62
63
  """
63
- if replace is not None:
64
- return _normalize_set(replace)
64
+ result = _normalize_set(replace) if replace is not None else base
65
65
  if extend is not None:
66
- return base | _normalize_set(extend)
67
- return base
66
+ result |= _normalize_set(extend)
67
+ return result
68
68
 
69
69
 
70
70
  @dataclass(frozen=True)
@@ -150,8 +150,8 @@ def set_defaults(
150
150
  built-in (False).
151
151
  truthy: Replaces the built-in truthy set.
152
152
  falsy: Replaces the built-in falsy set.
153
- extend_truthy: Extends the built-in truthy set.
154
- extend_falsy: Extends the built-in falsy set.
153
+ extend_truthy: Extends the truthy set (built-in, or truthy if given).
154
+ extend_falsy: Extends the falsy set (built-in, or falsy if given).
155
155
 
156
156
  Raises:
157
157
  TypeError: If strict/warn are not bool, or truthy/falsy/extend_truthy/
@@ -170,10 +170,10 @@ def set_defaults(
170
170
  new_defaults = Defaults(
171
171
  strict=strict if strict is not None else False,
172
172
  warn=warn if warn is not None else False,
173
- effective_truthy=_apply_replace_or_extend(
173
+ effective_truthy=_apply_replace_then_extend(
174
174
  DEFAULT_TRUTHY, truthy, extend_truthy
175
175
  ),
176
- effective_falsy=_apply_replace_or_extend(DEFAULT_FALSY, falsy, extend_falsy),
176
+ effective_falsy=_apply_replace_then_extend(DEFAULT_FALSY, falsy, extend_falsy),
177
177
  )
178
178
  with _cache.lock:
179
179
  _cache.value = new_defaults
@@ -43,14 +43,18 @@ def envbool(
43
43
  process-level defaults (set_defaults()) (default False).
44
44
  truthy: Replaces the effective truthy set.
45
45
  falsy: Replaces the effective falsy set.
46
- extend_truthy: Extends the effective truthy set.
47
- extend_falsy: Extends the effective falsy set.
46
+ extend_truthy: Extends the effective truthy set, after any truthy
47
+ replacement.
48
+ extend_falsy: Extends the effective falsy set, after any falsy
49
+ replacement.
48
50
 
49
51
  Returns:
50
52
  True if the env var value is in the truthy set, False otherwise.
51
53
 
52
54
  Raises:
53
55
  InvalidBoolValueError: In strict mode when the value is unrecognized.
56
+ ConflictingValuesError: In strict mode when the effective truthy and
57
+ falsy sets overlap.
54
58
  MissingEnvVarError: When required=True and the variable is unset.
55
59
  """
56
60
  # `required` distinguishes "absent from the environment" from "set but empty"
@@ -8,6 +8,7 @@ which predates envbool adoption keeps working without changes.
8
8
  Hierarchy:
9
9
  EnvBoolError(Exception)
10
10
  InvalidBoolValueError(EnvBoolError, ValueError)
11
+ ConflictingValuesError(EnvBoolError, ValueError)
11
12
  MissingEnvVarError(EnvBoolError, KeyError)
12
13
  """
13
14
 
@@ -42,6 +43,28 @@ class InvalidBoolValueError(EnvBoolError, ValueError):
42
43
  falsy: frozenset[str]
43
44
 
44
45
 
46
+ class ConflictingValuesError(EnvBoolError, ValueError):
47
+ """Raised in strict mode when the effective truthy and falsy sets overlap.
48
+
49
+ This is a configuration error, not a bad input value -- which is why it is
50
+ distinct from InvalidBoolValueError. It is raised on every strict call while
51
+ the conflict exists (not only when the value lands in the overlap), so a
52
+ misconfiguration fails immediately instead of waiting for a colliding token.
53
+ ValueError inheritance matches InvalidBoolValueError, so a single
54
+ ``except ValueError`` still covers every strict-mode failure.
55
+ """
56
+
57
+ # Set by the raising code after construction (see InvalidBoolValueError for
58
+ # why attributes live here rather than in __init__).
59
+
60
+ # Values present in both sets.
61
+ overlap: frozenset[str]
62
+
63
+ # The effective truthy and falsy sets that conflicted.
64
+ truthy: frozenset[str]
65
+ falsy: frozenset[str]
66
+
67
+
45
68
  class MissingEnvVarError(EnvBoolError, KeyError):
46
69
  """Raised by envbool(var, required=True) when var is not set in the environment.
47
70
 
File without changes
File without changes