envbool 0.4.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: envbool
3
- Version: 0.4.2
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
@@ -91,6 +91,22 @@ pip install envbool
91
91
  uv add envbool
92
92
  ```
93
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
+
94
110
  ## Usage
95
111
 
96
112
  ### The basics
@@ -113,6 +129,9 @@ surrounding whitespace.
113
129
 
114
130
  Pass `strict=True` to raise `InvalidBoolValueError` on anything outside the
115
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.
116
135
 
117
136
  ```python
118
137
  import sys
@@ -137,6 +156,16 @@ FEATURE = envbool("FEATURE_FLAG", extend_truthy={"enabled", "y"})
137
156
  LOCALE = envbool("USE_METRIC", truthy={"metric"}, falsy={"imperial"})
138
157
  ```
139
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
+
140
169
  ### Coercing arbitrary strings
141
170
 
142
171
  Use `to_bool` for values that don't come from the environment. It accepts the
@@ -177,6 +206,52 @@ built-in defaults → set_defaults() → function arguments / CLI flags
177
206
  `reset_defaults()` restores the built-ins — call it in a test fixture (see
178
207
  [Testing code that uses envbool](#testing-code-that-uses-envbool)).
179
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
+
180
255
  > Through 0.3.x, envbool read TOML config files (`envbool.toml`,
181
256
  > `[tool.envbool]`). 0.4.0 removed them in favor of `set_defaults()` — see
182
257
  > `CHANGELOG.md` for the rationale and migration note.
@@ -227,8 +302,10 @@ options:
227
302
  --truthy VALUE Replace the truthy set with VALUE (repeatable).
228
303
  --falsy VALUE Replace the falsy set with VALUE (repeatable).
229
304
  --extend-truthy VALUE
230
- Add VALUE to the truthy set (repeatable).
231
- --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).
232
309
  ```
233
310
 
234
311
  A few rules worth knowing:
@@ -242,6 +319,29 @@ A few rules worth knowing:
242
319
  - With no `VAR_NAME`, `--value`, or non-empty piped stdin, the CLI prints
243
320
  usage and exits `2`.
244
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
+
245
345
  ## API reference
246
346
 
247
347
  | Symbol | Description |
@@ -256,6 +356,7 @@ A few rules worth knowing:
256
356
  | `DEFAULT_FALSY` | `frozenset` of the built-in falsy strings. |
257
357
  | `EnvBoolError` | Base class for every exception the library raises. |
258
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`. |
259
360
  | `MissingEnvVarError` | Raised by `envbool(required=True)` when the variable is unset. Also a `KeyError`. |
260
361
 
261
362
  `envbool()` and `to_bool()` share the same keyword-only options:
@@ -266,7 +367,7 @@ A few rules worth knowing:
266
367
  | `strict` | `bool \| None` | `None` | Raise on unrecognized values (`None` defers to `set_defaults()`). |
267
368
  | `warn` | `bool \| None` | `None` | Log a warning on unrecognized values (`None` defers to `set_defaults()`). |
268
369
  | `truthy` / `falsy` | `Iterable[str] \| None` | `None` | **Replace** the effective set. |
269
- | `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. |
270
371
 
271
372
  `envbool()` also accepts `required` (`bool`, default `False`): when `True`, a
272
373
  variable that is unset raises `MissingEnvVarError` before `default` is applied. A
@@ -317,7 +418,7 @@ logging.getLogger("envbool").addHandler(logging.StreamHandler())
317
418
  | Level | When |
318
419
  | --- | --- |
319
420
  | `WARNING` | An unrecognized value fell through in lenient mode (only when `warn=True`). |
320
- | `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). |
321
422
 
322
423
  ### The unset-vs-empty distinction
323
424
 
@@ -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
@@ -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
@@ -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
@@ -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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "envbool"
3
- version = "0.4.2"
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"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "envbool"
3
- version = "0.4.2"
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" }]
@@ -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
File without changes