envbool 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
envbool/__init__.py ADDED
@@ -0,0 +1,42 @@
1
+ """envbool -- coerce environment variables and strings into booleans.
2
+
3
+ Import everything you need directly from this package:
4
+
5
+ from envbool import envbool, to_bool, InvalidBoolValueError
6
+
7
+ For except clauses, envbool.exceptions is also importable by name:
8
+
9
+ from envbool.exceptions import InvalidBoolValueError
10
+
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
+ load_config() -- inspect or preload the process-level config cache
15
+ EnvBoolConfig -- frozen dataclass returned by load_config()
16
+ DEFAULT_TRUTHY -- built-in truthy set (frozenset)
17
+ DEFAULT_FALSY -- built-in falsy set (frozenset)
18
+ EnvBoolError -- base exception for all envbool errors
19
+ InvalidBoolValueError -- raised in strict mode for unrecognized values
20
+ ConfigError -- raised for malformed or unreadable config files
21
+ """
22
+ # All implementation lives in private underscore-prefixed modules so the public
23
+ # surface can be reshaped without breaking imports. Do not import from _core,
24
+ # _env, _config, _cli, or _defaults directly.
25
+
26
+ from envbool._config import EnvBoolConfig, load_config
27
+ from envbool._core import to_bool
28
+ from envbool._defaults import DEFAULT_FALSY, DEFAULT_TRUTHY
29
+ from envbool._env import envbool
30
+ from envbool.exceptions import ConfigError, EnvBoolError, InvalidBoolValueError
31
+
32
+ __all__ = [
33
+ "DEFAULT_FALSY",
34
+ "DEFAULT_TRUTHY",
35
+ "ConfigError",
36
+ "EnvBoolConfig",
37
+ "EnvBoolError",
38
+ "InvalidBoolValueError",
39
+ "envbool",
40
+ "load_config",
41
+ "to_bool",
42
+ ]
envbool/_cli.py ADDED
@@ -0,0 +1,121 @@
1
+ """envbool CLI -- coerce an environment variable or string to a boolean.
2
+
3
+ Usage: envbool [OPTIONS] [VAR_NAME]
4
+
5
+ Input source (first match wins):
6
+ 1. --value TEXT -- coerce a literal string directly
7
+ 2. VAR_NAME -- read and coerce an environment variable
8
+ 3. stdin pipe -- read a single value from stdin (piped or redirected)
9
+ If none apply, prints usage and exits 2.
10
+
11
+ Exit codes:
12
+ 0 -- truthy
13
+ 1 -- falsy or unset/empty
14
+ 2 -- error (unrecognized value in strict mode, bad arguments, multi-line stdin)
15
+
16
+ Omitting --strict defers to the config file setting (default: lenient).
17
+
18
+ Public surface:
19
+ main() -- entry point registered as the "envbool" command
20
+ """
21
+ # --strict uses default=None rather than False so an absent flag passes None
22
+ # through to envbool()/to_bool(), which then defers to the loaded config
23
+ # instead of overriding a config-level strict = true with False.
24
+
25
+ __all__ = ["main"]
26
+
27
+ import argparse
28
+ import sys
29
+
30
+ from envbool._core import to_bool
31
+ from envbool._env import envbool
32
+ from envbool.exceptions import InvalidBoolValueError
33
+
34
+
35
+ def _build_parser() -> argparse.ArgumentParser:
36
+ """Build and return the argument parser."""
37
+ # Separated from main() so tests can call it directly without going through
38
+ # the full parse-and-exit cycle.
39
+ parser = argparse.ArgumentParser(
40
+ prog="envbool",
41
+ description="Coerce an environment variable or string to a boolean.",
42
+ )
43
+ parser.add_argument(
44
+ "var",
45
+ nargs="?",
46
+ metavar="VAR_NAME",
47
+ help="Environment variable name to check.",
48
+ )
49
+ parser.add_argument(
50
+ "--value",
51
+ "-v",
52
+ metavar="TEXT",
53
+ help="Check a literal string instead of an env var.",
54
+ )
55
+ parser.add_argument(
56
+ "--strict",
57
+ "-s",
58
+ action="store_true",
59
+ # None so an absent flag defers to config rather than overriding it with False.
60
+ # store_true with default=None gives: flag present -> True, absent -> None.
61
+ default=None,
62
+ help="Raise error on unrecognized values.",
63
+ )
64
+ parser.add_argument(
65
+ "--default",
66
+ "-d",
67
+ action="store_true",
68
+ default=False,
69
+ help="Default value if unset/empty (default: false).",
70
+ )
71
+ parser.add_argument(
72
+ "--print",
73
+ "-p",
74
+ dest="print_result",
75
+ action="store_true",
76
+ help='Print "true" or "false" instead of using exit codes.',
77
+ )
78
+ return parser
79
+
80
+
81
+ def main() -> None:
82
+ """Parse arguments, resolve the input source, and exit with the appropriate code."""
83
+ # All coercion logic lives in _core.py; this function is pure I/O plumbing.
84
+ parser = _build_parser()
85
+ args = parser.parse_args()
86
+
87
+ # --value and VAR_NAME are mutually exclusive. Using argparse's built-in
88
+ # add_mutually_exclusive_group would place them in a separate usage section,
89
+ # which makes the help text harder to read, so we validate manually instead.
90
+ if args.value is not None and args.var is not None:
91
+ parser.error("VAR_NAME and --value are mutually exclusive")
92
+
93
+ try:
94
+ if args.value is not None:
95
+ result = to_bool(args.value, strict=args.strict, default=args.default)
96
+ elif args.var is not None:
97
+ result = envbool(args.var, strict=args.strict, default=args.default)
98
+ elif not sys.stdin.isatty():
99
+ # Non-TTY stdin means the user piped or redirected input. Strip surrounding
100
+ # whitespace (handles the trailing newline echo adds) then reject anything
101
+ # with an embedded newline -- only a single value is meaningful here.
102
+ raw = sys.stdin.read().strip()
103
+ if "\n" in raw:
104
+ print(
105
+ "error: stdin must contain a single value, not multiple lines",
106
+ file=sys.stderr,
107
+ )
108
+ sys.exit(2)
109
+ result = to_bool(raw, strict=args.strict, default=args.default)
110
+ else:
111
+ parser.print_usage(sys.stderr)
112
+ sys.exit(2)
113
+ except InvalidBoolValueError as e:
114
+ print(f"error: {e}", file=sys.stderr)
115
+ sys.exit(2)
116
+
117
+ if args.print_result:
118
+ print("true" if result else "false")
119
+ sys.exit(0)
120
+ else:
121
+ sys.exit(0 if result else 1)
envbool/_config.py ADDED
@@ -0,0 +1,374 @@
1
+ """Config file discovery, loading, caching, and EnvBoolConfig dataclass.
2
+
3
+ Discovery priority (first found wins):
4
+ 1. Project-level: walk up from CWD looking for envbool.toml or [tool.envbool]
5
+ in pyproject.toml. Walk stops at boundary markers or pyproject.toml.
6
+ 2. User-level: <platformdirs.user_config_dir("envbool")>/config.toml
7
+
8
+ The config is loaded once and cached for the lifetime of the process (thread-safe).
9
+ Call _reset_config() in test fixtures to clear it.
10
+
11
+ Public surface:
12
+ EnvBoolConfig -- frozen dataclass with resolved settings
13
+ load_config() -- returns the cached (or freshly loaded) config
14
+ _reset_config() -- clears the cache; for test use only
15
+ """
16
+
17
+ import logging
18
+ import os
19
+ import threading
20
+ import tomllib
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+
24
+ import platformdirs
25
+
26
+ from envbool._defaults import DEFAULT_FALSY, DEFAULT_TRUTHY
27
+ from envbool.exceptions import ConfigError
28
+
29
+ __all__ = ["EnvBoolConfig", "load_config"]
30
+
31
+ _logger = logging.getLogger(__name__)
32
+
33
+ # Boundary markers that signal "you have reached the project root -- stop walking."
34
+ # pyproject.toml is handled separately because it is both a boundary and a potential
35
+ # config source (checked for [tool.envbool] before the walk stops).
36
+ _BOUNDARY_MARKERS: frozenset[str] = frozenset({".git", ".hg", "setup.py", "setup.cfg"})
37
+
38
+ # Safety cap: never walk more than this many directory levels from CWD. Prevents
39
+ # runaway traversal in CI/CD environments or Docker containers without markers.
40
+ _MAX_WALK_DEPTH: int = 10
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class EnvBoolConfig:
45
+ """Resolved configuration for this process, loaded from a config file or defaults.
46
+
47
+ Attributes:
48
+ strict: When True, unrecognized values raise InvalidBoolValueError.
49
+ warn: When True, unrecognized values in lenient mode emit a WARNING log.
50
+ effective_truthy: Fully resolved truthy set (after extend/replace logic).
51
+ effective_falsy: Fully resolved falsy set (after extend/replace logic).
52
+ source_path: Which file was loaded, or None if using hardcoded defaults.
53
+ """
54
+
55
+ strict: bool = False
56
+ warn: bool = False
57
+ # field() is required here because frozenset is not a primitive type -- Python's
58
+ # dataclass machinery rejects non-primitive mutable-looking defaults without it,
59
+ # even though frozenset is immutable.
60
+ effective_truthy: frozenset[str] = field(default_factory=lambda: DEFAULT_TRUTHY)
61
+ effective_falsy: frozenset[str] = field(default_factory=lambda: DEFAULT_FALSY)
62
+ source_path: Path | None = None
63
+
64
+
65
+ class _ConfigCache:
66
+ # A class attribute instead of a bare module-level variable avoids the need
67
+ # for 'global' statements when updating the cached value. The lock lives here
68
+ # so all cache state travels together.
69
+
70
+ config: EnvBoolConfig | None = None
71
+ lock: threading.Lock = threading.Lock()
72
+
73
+
74
+ _cache = _ConfigCache()
75
+
76
+
77
+ def load_config() -> EnvBoolConfig:
78
+ """Return the active config, loading from disk on first call.
79
+
80
+ Subsequent calls return the cached instance with no disk I/O. Use this to
81
+ inspect or preload the config at application startup.
82
+
83
+ Returns:
84
+ The resolved EnvBoolConfig for this process.
85
+
86
+ Raises:
87
+ ConfigError: If a config file is found but malformed or has invalid values.
88
+ """
89
+ return _get_config()
90
+
91
+
92
+ def _get_config() -> EnvBoolConfig:
93
+ # Double-checked locking: the outer check avoids lock contention on the hot
94
+ # path (all calls after first load); the inner check prevents duplicate disk
95
+ # I/O if two threads race on the very first call.
96
+ if _cache.config is not None:
97
+ return _cache.config
98
+ with _cache.lock:
99
+ if _cache.config is not None: # another thread may have loaded while we waited
100
+ return _cache.config
101
+ _cache.config = _load_config_from_disk()
102
+ return _cache.config
103
+
104
+
105
+ def _reset_config() -> None:
106
+ """Clear the cached config so the next call reloads from disk.
107
+
108
+ For test use only -- not part of the public API. Intended to be called in a
109
+ pytest autouse fixture so each test starts with a clean slate:
110
+
111
+ @pytest.fixture(autouse=True)
112
+ def _reset_envbool_config():
113
+ yield
114
+ envbool._reset_config()
115
+ """
116
+ with _cache.lock:
117
+ _cache.config = None
118
+
119
+
120
+ def _load_config_from_disk() -> EnvBoolConfig:
121
+ """Discover and parse the config file, returning EnvBoolConfig.
122
+
123
+ Uses a raw os.environ.get() -- not envbool() itself -- to check
124
+ ENVBOOL_NO_CONFIG. Calling envbool() here would trigger _get_config() again
125
+ before _config is set, causing infinite recursion.
126
+ """
127
+ if os.environ.get("ENVBOOL_NO_CONFIG") == "1":
128
+ _logger.debug("ENVBOOL_NO_CONFIG=1 -- skipping config file discovery")
129
+ return EnvBoolConfig()
130
+
131
+ # Project-level: walk up from CWD
132
+ project_config = _find_project_config()
133
+ if project_config is not None:
134
+ return project_config
135
+
136
+ # User-level: platformdirs fallback
137
+ user_config = _find_user_config()
138
+ if user_config is not None:
139
+ return user_config
140
+
141
+ _logger.debug("No config file found -- using hardcoded defaults")
142
+ return EnvBoolConfig()
143
+
144
+
145
+ def _find_project_config() -> EnvBoolConfig | None:
146
+ """Walk up the directory tree from CWD looking for a project-level config.
147
+
148
+ Returns the parsed EnvBoolConfig if a config is found, None otherwise.
149
+ Stops early at boundary markers or after _MAX_WALK_DEPTH levels.
150
+ """
151
+ current = Path.cwd()
152
+ for _ in range(_MAX_WALK_DEPTH):
153
+ # envbool.toml takes priority over pyproject.toml in the same directory.
154
+ envbool_toml = current / "envbool.toml"
155
+ if envbool_toml.is_file():
156
+ _logger.debug("Found config: %s", envbool_toml)
157
+ return _parse_toml_file(envbool_toml)
158
+
159
+ pyproject = current / "pyproject.toml"
160
+ if pyproject.is_file():
161
+ # pyproject.toml is both a potential config source AND a boundary marker.
162
+ # We check for [tool.envbool] first; if present, use it. Either way, stop
163
+ # walking -- a pyproject.toml signals "you've reached the project root."
164
+ config = _try_pyproject(pyproject)
165
+ if config is not None:
166
+ _logger.debug("Found config in [tool.envbool]: %s", pyproject)
167
+ else:
168
+ _logger.debug(
169
+ "pyproject.toml has no [tool.envbool] -- stopping walk at %s",
170
+ current,
171
+ )
172
+ return config # may be None -- caller treats None as "not found"
173
+
174
+ # Standard boundary markers (not config sources, just stop signals).
175
+ if any((current / marker).exists() for marker in _BOUNDARY_MARKERS):
176
+ _logger.debug("Boundary marker found at %s -- stopping walk", current)
177
+ return None
178
+
179
+ parent = current.parent
180
+ if parent == current:
181
+ # Reached filesystem root
182
+ return None
183
+ current = parent
184
+
185
+ _logger.debug("Depth cap (%d) reached -- stopping walk", _MAX_WALK_DEPTH)
186
+ return None
187
+
188
+
189
+ def _find_user_config() -> EnvBoolConfig | None:
190
+ """Check the platformdirs user config directory for config.toml.
191
+
192
+ Returns the parsed EnvBoolConfig if found, None if the file does not exist.
193
+ If platformdirs cannot determine the config dir (very rare), returns None
194
+ rather than crashing.
195
+ """
196
+ try:
197
+ config_dir = Path(platformdirs.user_config_dir("envbool"))
198
+ except Exception: # noqa: BLE001 -- platformdirs failure is non-fatal
199
+ _logger.debug("platformdirs could not determine user config dir -- skipping")
200
+ return None
201
+
202
+ config_file = config_dir / "config.toml"
203
+ if not config_file.is_file():
204
+ return None
205
+
206
+ _logger.debug("Found user config: %s", config_file)
207
+ return _parse_toml_file(config_file)
208
+
209
+
210
+ def _try_pyproject(path: Path) -> EnvBoolConfig | None:
211
+ """Parse path as a pyproject.toml and return config from [tool.envbool].
212
+
213
+ Returns None (not an error) if the section is absent -- the caller
214
+ interprets that as "stop walking but no config found."
215
+
216
+ Raises:
217
+ ConfigError: If the TOML is malformed or [tool.envbool] has invalid values.
218
+ """
219
+ try:
220
+ with path.open("rb") as f:
221
+ data = tomllib.load(f)
222
+ except tomllib.TOMLDecodeError as exc:
223
+ err = ConfigError(f"Malformed TOML in {path}: {exc}")
224
+ err.path = path
225
+ raise err from exc
226
+
227
+ section = data.get("tool", {}).get("envbool")
228
+ if section is None:
229
+ return None
230
+ if not isinstance(section, dict):
231
+ err = ConfigError(f"[tool.envbool] must be a table in {path}")
232
+ err.path = path
233
+ raise err
234
+ return _parse_config(section, path)
235
+
236
+
237
+ def _parse_toml_file(path: Path) -> EnvBoolConfig:
238
+ """Read and parse a standalone TOML config file (envbool.toml or user config.toml).
239
+
240
+ Args:
241
+ path: Path to the TOML file.
242
+
243
+ Returns:
244
+ Parsed and validated EnvBoolConfig.
245
+
246
+ Raises:
247
+ ConfigError: If the file is malformed TOML or contains invalid values.
248
+ """
249
+ try:
250
+ with path.open("rb") as f:
251
+ data = tomllib.load(f)
252
+ except tomllib.TOMLDecodeError as exc:
253
+ err = ConfigError(f"Malformed TOML in {path}: {exc}")
254
+ err.path = path
255
+ raise err from exc
256
+
257
+ return _parse_config(data, path)
258
+
259
+
260
+ def _parse_config(data: dict, path: Path) -> EnvBoolConfig:
261
+ """Validate a raw TOML dict and resolve it into an EnvBoolConfig.
262
+
263
+ Unknown keys are silently ignored so config files stay forward-compatible as
264
+ new options are added. Known keys are type-checked strictly -- wrong types
265
+ (e.g. strict = "yes" instead of strict = true) raise ConfigError with a message
266
+ that names the expected type, since a type mismatch is almost certainly a typo.
267
+
268
+ The extend/replace logic here mirrors _resolve() in core.py:
269
+ - "truthy" replaces DEFAULT_TRUTHY entirely
270
+ - "extend_truthy" adds to DEFAULT_TRUTHY
271
+ - "truthy" takes priority when both present (ruff's select/extend-select pattern)
272
+
273
+ Args:
274
+ data: Parsed TOML key/value pairs (the [tool.envbool] section or top-level).
275
+ path: Source file path, attached to any ConfigError for diagnostics.
276
+
277
+ Returns:
278
+ Resolved EnvBoolConfig with effective_truthy/effective_falsy fully computed.
279
+
280
+ Raises:
281
+ ConfigError: For type mismatches or malformed list elements.
282
+ """
283
+ # --- bool fields ---
284
+ strict = _get_bool_field(data, "strict", path)
285
+ warn = _get_bool_field(data, "warn", path)
286
+
287
+ # --- set fields: apply extend/replace logic ---
288
+ # truthy replaces DEFAULT_TRUTHY; extend_truthy adds to it; truthy wins if both.
289
+ raw_truthy = _get_str_list_field(data, "truthy", path)
290
+ raw_extend_truthy = _get_str_list_field(data, "extend_truthy", path)
291
+ if raw_truthy is not None:
292
+ effective_truthy = _normalize_set(raw_truthy)
293
+ elif raw_extend_truthy is not None:
294
+ effective_truthy = DEFAULT_TRUTHY | _normalize_set(raw_extend_truthy)
295
+ else:
296
+ effective_truthy = DEFAULT_TRUTHY
297
+
298
+ # Same extend/replace logic for the falsy side, independent of truthy.
299
+ raw_falsy = _get_str_list_field(data, "falsy", path)
300
+ raw_extend_falsy = _get_str_list_field(data, "extend_falsy", path)
301
+ if raw_falsy is not None:
302
+ effective_falsy = _normalize_set(raw_falsy)
303
+ elif raw_extend_falsy is not None:
304
+ effective_falsy = DEFAULT_FALSY | _normalize_set(raw_extend_falsy)
305
+ else:
306
+ effective_falsy = DEFAULT_FALSY
307
+
308
+ _logger.debug(
309
+ "Config loaded from %s: strict=%s warn=%s truthy=%s falsy=%s",
310
+ path,
311
+ strict,
312
+ warn,
313
+ sorted(effective_truthy),
314
+ sorted(effective_falsy),
315
+ )
316
+
317
+ return EnvBoolConfig(
318
+ strict=strict if strict is not None else False,
319
+ warn=warn if warn is not None else False,
320
+ effective_truthy=effective_truthy,
321
+ effective_falsy=effective_falsy,
322
+ source_path=path,
323
+ )
324
+
325
+
326
+ def _normalize_set(values: list[str]) -> frozenset[str]:
327
+ """Strip and lowercase values so they match to_bool()'s normalized input."""
328
+ return frozenset(v.strip().lower() for v in values)
329
+
330
+
331
+ def _get_bool_field(data: dict, key: str, path: Path) -> bool | None:
332
+ """Extract a bool field from data, returning None if absent.
333
+
334
+ Raises:
335
+ ConfigError: If the key is present but not a Python bool (TOML boolean).
336
+ """
337
+ if key not in data:
338
+ return None
339
+ value = data[key]
340
+ if not isinstance(value, bool):
341
+ err = ConfigError(
342
+ f"Config error in {path}: '{key}' must be a boolean (true or false),"
343
+ f" got {type(value).__name__!r}"
344
+ )
345
+ err.path = path
346
+ raise err
347
+ return value
348
+
349
+
350
+ def _get_str_list_field(data: dict, key: str, path: Path) -> list[str] | None:
351
+ """Extract a list-of-strings field from data, returning None if absent.
352
+
353
+ Raises:
354
+ ConfigError: If the key is present but not a list, or contains non-strings.
355
+ """
356
+ if key not in data:
357
+ return None
358
+ value = data[key]
359
+ if not isinstance(value, list):
360
+ err = ConfigError(
361
+ f"Config error in {path}: '{key}' must be an array of strings,"
362
+ f" got {type(value).__name__!r}"
363
+ )
364
+ err.path = path
365
+ raise err
366
+ for i, item in enumerate(value):
367
+ if not isinstance(item, str):
368
+ err = ConfigError(
369
+ f"Config error in {path}: '{key}[{i}]' must be a string,"
370
+ f" got {type(item).__name__!r}"
371
+ )
372
+ err.path = path
373
+ raise err
374
+ return value
envbool/_core.py ADDED
@@ -0,0 +1,177 @@
1
+ """Pure string-to-bool coercion with configurable truthy/falsy sets.
2
+
3
+ Public surface:
4
+ DEFAULT_TRUTHY -- the built-in truthy set (from _defaults)
5
+ DEFAULT_FALSY -- the built-in falsy set (from _defaults)
6
+ to_bool() -- coerce a single string to bool
7
+
8
+ Private surface (used by _env.py and tests):
9
+ _resolve() -- compute effective truthy/falsy sets from layered inputs
10
+ """
11
+ # This module has no knowledge of os.environ -- that lives in _env.py. It does
12
+ # consult the config cache (_get_config) so that strict=None/warn=None defer to
13
+ # the loaded config file rather than always defaulting to False.
14
+ # Import order matters: _config.py imports _defaults (not _core), so _core.py
15
+ # can safely import from _config.py without creating a circular dependency.
16
+
17
+ __all__ = ["to_bool"]
18
+
19
+ import logging
20
+ from collections.abc import Iterable
21
+
22
+ from envbool._config import _get_config
23
+ from envbool._defaults import DEFAULT_FALSY, DEFAULT_TRUTHY
24
+ from envbool.exceptions import InvalidBoolValueError
25
+
26
+ # Module-level logger -- attributed to "envbool._core" so callers can filter it
27
+ # independently from "envbool.config" or the root "envbool" logger.
28
+ _logger = logging.getLogger(__name__)
29
+
30
+ # Public API
31
+
32
+
33
+ def to_bool(
34
+ value: str,
35
+ *,
36
+ default: bool = False,
37
+ strict: bool | None = None,
38
+ warn: bool | None = None,
39
+ truthy: Iterable[str] | None = None,
40
+ falsy: Iterable[str] | None = None,
41
+ extend_truthy: Iterable[str] | None = None,
42
+ extend_falsy: Iterable[str] | None = None,
43
+ _var: str | None = None,
44
+ ) -> bool:
45
+ """Coerce a string to bool.
46
+
47
+ Args:
48
+ value: The string to coerce.
49
+ default: Returned when value is empty or unset.
50
+ strict: Raise on unrecognized values. None defers to config (default False).
51
+ warn: Log a warning on unrecognized values. None defers to config
52
+ (default False).
53
+ truthy: Replaces the effective truthy set.
54
+ falsy: Replaces the effective falsy set.
55
+ extend_truthy: Extends the effective truthy set.
56
+ extend_falsy: Extends the effective falsy set.
57
+ _var: Internal - env var name for error messages when called via envbool().
58
+
59
+ Returns:
60
+ True if value is in the truthy set, False otherwise.
61
+
62
+ Raises:
63
+ InvalidBoolValueError: In strict mode when value is unrecognized.
64
+ """
65
+ # Normalize first so all comparisons are case- and whitespace-insensitive.
66
+ # Empty after normalization means "unset" -- return the caller's default
67
+ # rather than treating it as an unrecognized value.
68
+ normalized = value.strip().lower()
69
+ if not normalized:
70
+ return default
71
+
72
+ # Load config once per process (cached after first call -- no per-call disk I/O).
73
+ # _resolve then applies the full three-level precedence chain:
74
+ # hardcoded defaults (_defaults.py)
75
+ # -> config file (effective_truthy/effective_falsy already resolved there)
76
+ # -> call-site args (truthy/extend_truthy/falsy/extend_falsy)
77
+ config = _get_config()
78
+ effective_truthy, effective_falsy = _resolve(
79
+ config_truthy=config.effective_truthy,
80
+ config_falsy=config.effective_falsy,
81
+ truthy=truthy,
82
+ falsy=falsy,
83
+ extend_truthy=extend_truthy,
84
+ extend_falsy=extend_falsy,
85
+ )
86
+
87
+ # Overlapping sets are a caller mistake, not a runtime error. Warn so the
88
+ # problem is visible, then let truthy win to stay consistent and predictable.
89
+ overlap = effective_truthy & effective_falsy
90
+ if overlap:
91
+ _logger.warning(
92
+ "Overlapping truthy/falsy values (truthy wins): %s", sorted(overlap)
93
+ )
94
+
95
+ if normalized in effective_truthy:
96
+ return True
97
+
98
+ # Falsy is checked after truthy so the overlap rule above is enforced
99
+ # without any extra branching.
100
+ if normalized in effective_falsy:
101
+ return False
102
+
103
+ # Three-state logic: True/False at the call site override the config value;
104
+ # None defers to whatever the config file says (which defaults to False if no
105
+ # config file exists).
106
+ effective_strict = strict if strict is not None else config.strict
107
+ if effective_strict:
108
+ truthy_list = ", ".join(sorted(effective_truthy))
109
+ falsy_list = ", ".join(sorted(effective_falsy))
110
+ # _var is threaded in by envbool() so the error message names the env
111
+ # var; it's a private param to keep it out of the public to_bool() API.
112
+ if _var is not None:
113
+ msg = (
114
+ f"Invalid boolean value for {_var}: {normalized!r}\n"
115
+ f" Expected truthy: {truthy_list}\n"
116
+ f" Expected falsy: {falsy_list}"
117
+ )
118
+ else:
119
+ msg = (
120
+ f"Invalid boolean value: {normalized!r}\n"
121
+ f" Expected truthy: {truthy_list}\n"
122
+ f" Expected falsy: {falsy_list}"
123
+ )
124
+ err = InvalidBoolValueError(msg)
125
+ err.var = _var
126
+ err.value = normalized
127
+ err.truthy = effective_truthy
128
+ err.falsy = effective_falsy
129
+ raise err
130
+
131
+ effective_warn = warn if warn is not None else config.warn
132
+ if effective_warn:
133
+ _logger.warning("Unrecognized boolean value: %r", normalized)
134
+
135
+ # Lenient fallback: anything unrecognized is treated as falsy. This matches
136
+ # the "off by default" mental model of environment variable feature flags.
137
+ return False
138
+
139
+
140
+ # Private API
141
+
142
+
143
+ def _normalize_set(values: Iterable[str]) -> frozenset[str]:
144
+ """Strip and lowercase values so they match to_bool()'s normalized input."""
145
+ return frozenset(v.strip().lower() for v in values)
146
+
147
+
148
+ def _resolve(
149
+ *,
150
+ config_truthy: frozenset[str] = DEFAULT_TRUTHY,
151
+ config_falsy: frozenset[str] = DEFAULT_FALSY,
152
+ truthy: Iterable[str] | None = None,
153
+ falsy: Iterable[str] | None = None,
154
+ extend_truthy: Iterable[str] | None = None,
155
+ extend_falsy: Iterable[str] | None = None,
156
+ ) -> tuple[frozenset[str], frozenset[str]]:
157
+ # Priority mirrors ruff's select/extend-select pattern:
158
+ # truthy -- full replacement; caller owns the entire set
159
+ # extend_truthy -- additive; merges on top of config_truthy
160
+ # (neither) -- use config_truthy as-is (defaults when no config file)
161
+ # truthy takes precedence over extend_truthy; both cannot apply at once.
162
+ if truthy is not None:
163
+ effective_truthy = _normalize_set(truthy)
164
+ elif extend_truthy is not None:
165
+ effective_truthy = config_truthy | _normalize_set(extend_truthy)
166
+ else:
167
+ effective_truthy = config_truthy
168
+
169
+ # Same three-level logic for the falsy side, independent of truthy.
170
+ if falsy is not None:
171
+ effective_falsy = _normalize_set(falsy)
172
+ elif extend_falsy is not None:
173
+ effective_falsy = config_falsy | _normalize_set(extend_falsy)
174
+ else:
175
+ effective_falsy = config_falsy
176
+
177
+ return (effective_truthy, effective_falsy)
envbool/_defaults.py ADDED
@@ -0,0 +1,14 @@
1
+ """Built-in truthy/falsy value sets -- the baseline when no overrides are provided.
2
+
3
+ These are intentionally small: common, unambiguous tokens only. Add project-specific
4
+ values like "enabled", "disabled", "y", "n" via extend_truthy / extend_falsy rather
5
+ than expecting them here.
6
+ """
7
+ # Both _core.py and _config.py import these constants. Keeping them in a separate
8
+ # module prevents the circular import that would arise if _config.py imported from
9
+ # _core.py (which imports _get_config from _config.py).
10
+
11
+ __all__ = ["DEFAULT_FALSY", "DEFAULT_TRUTHY"]
12
+
13
+ DEFAULT_TRUTHY: frozenset[str] = frozenset({"true", "1", "yes", "on"})
14
+ DEFAULT_FALSY: frozenset[str] = frozenset({"false", "0", "no", "off"})
envbool/_env.py ADDED
@@ -0,0 +1,65 @@
1
+ """envbool() -- read an environment variable and coerce it to bool.
2
+
3
+ Public surface:
4
+ envbool() -- the primary library API
5
+ """
6
+ # This is the only layer in the package that touches os.environ. The split
7
+ # between _env.py and _core.py keeps os.environ access isolated here so that
8
+ # to_bool() can be tested without monkeypatching the environment.
9
+ # Delegation chain: envbool() -> to_bool() -> _resolve() (+ _get_config())
10
+
11
+ __all__ = ["envbool"]
12
+
13
+ import os
14
+ from collections.abc import Iterable
15
+
16
+ from envbool._core import to_bool
17
+
18
+
19
+ def envbool(
20
+ var: str,
21
+ *,
22
+ default: bool = False,
23
+ strict: bool | None = None,
24
+ warn: bool | None = None,
25
+ truthy: Iterable[str] | None = None,
26
+ falsy: Iterable[str] | None = None,
27
+ extend_truthy: Iterable[str] | None = None,
28
+ extend_falsy: Iterable[str] | None = None,
29
+ ) -> bool:
30
+ """Read an environment variable and coerce its value to bool.
31
+
32
+ Args:
33
+ var: Environment variable name.
34
+ default: Returned when the variable is unset or empty.
35
+ strict: Raise on unrecognized values. None defers to config (default False).
36
+ warn: Log a warning on unrecognized values. None defers to config
37
+ (default False).
38
+ truthy: Replaces the effective truthy set.
39
+ falsy: Replaces the effective falsy set.
40
+ extend_truthy: Extends the effective truthy set.
41
+ extend_falsy: Extends the effective falsy set.
42
+
43
+ Returns:
44
+ True if the env var value is in the truthy set, False otherwise.
45
+
46
+ Raises:
47
+ InvalidBoolValueError: In strict mode when the value is unrecognized.
48
+ """
49
+ # Missing var becomes "" so to_bool treats it the same as an empty value,
50
+ # returning `default` rather than raising or treating absence as a distinct state.
51
+ value = os.environ.get(var, "")
52
+ # _var=var threads the env var name into any InvalidBoolValueError so the message
53
+ # reads "Invalid boolean value for DEBUG: 'maybe'" instead of just the value.
54
+ # It is a private parameter to keep it out of to_bool()'s public signature.
55
+ return to_bool(
56
+ value,
57
+ default=default,
58
+ strict=strict,
59
+ warn=warn,
60
+ truthy=truthy,
61
+ falsy=falsy,
62
+ extend_truthy=extend_truthy,
63
+ extend_falsy=extend_falsy,
64
+ _var=var,
65
+ )
envbool/exceptions.py ADDED
@@ -0,0 +1,55 @@
1
+ """Exception hierarchy for envbool.
2
+
3
+ All exceptions inherit from EnvBoolError so callers can catch the entire
4
+ library with a single except clause. Where it makes sense, exceptions also
5
+ inherit from a standard Python exception (ValueError, etc.) so that code
6
+ which predates envbool adoption keeps working without changes.
7
+
8
+ Hierarchy:
9
+ EnvBoolError(Exception)
10
+ InvalidBoolValueError(EnvBoolError, ValueError)
11
+ ConfigError(EnvBoolError)
12
+ """
13
+
14
+ from pathlib import Path
15
+
16
+
17
+ class EnvBoolError(Exception):
18
+ """Base exception for all envbool errors.
19
+
20
+ Catch this to handle any error from the library in one place.
21
+ """
22
+
23
+
24
+ class InvalidBoolValueError(EnvBoolError, ValueError):
25
+ """Raised in strict mode when a value is not in the truthy or falsy sets.
26
+
27
+ Dual inheritance lets existing except ValueError handlers keep working
28
+ after a codebase adopts envbool, with no migration required.
29
+ """
30
+
31
+ # Attributes are set by the raising code after construction rather than in
32
+ # __init__ to keep the signature simple and avoid pickling/subclassing issues.
33
+
34
+ # Name of the environment variable, if the error originated from envbool().
35
+ # None when raised directly from to_bool() with no env var context.
36
+ var: str | None
37
+
38
+ # The normalized (stripped, lowercased) value that was not recognized.
39
+ value: str
40
+
41
+ # The effective truthy and falsy sets at the time of the error. Attached
42
+ # so callers can inspect exactly what was expected without re-running resolution.
43
+ truthy: frozenset[str]
44
+ falsy: frozenset[str]
45
+
46
+
47
+ class ConfigError(EnvBoolError):
48
+ """Raised when a config file is malformed or contains invalid values.
49
+
50
+ Not a ValueError -- config problems are distinct from bad boolean values
51
+ and should not be caught by generic except ValueError handlers.
52
+ """
53
+
54
+ # Path to the config file that caused the error, for diagnostic messages.
55
+ path: Path
envbool/py.typed ADDED
File without changes
@@ -0,0 +1,150 @@
1
+ Metadata-Version: 2.4
2
+ Name: envbool
3
+ Version: 0.1.0
4
+ Summary: A small Python library and CLI tool for coercing environment variables (and arbitrary strings) into boolean values.
5
+ Author: Kyle O'Malley
6
+ Author-email: Kyle O'Malley <j.kyle.omalley@gmail.com>
7
+ License-Expression: MIT
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
11
+ Classifier: Topic :: Utilities
12
+ Classifier: Environment :: Console
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Typing :: Typed
20
+ Requires-Dist: platformdirs>=4.9.6
21
+ Requires-Python: >=3.11
22
+ Description-Content-Type: text/markdown
23
+
24
+ # envbool
25
+
26
+ Coerce environment variables to booleans.
27
+
28
+ [![PyPI version](https://img.shields.io/pypi/v/envbool)](https://pypi.org/project/envbool/)
29
+ [![Python versions](https://img.shields.io/pypi/pyversions/envbool)](https://pypi.org/project/envbool/)
30
+ [![License: MIT](https://img.shields.io/pypi/l/envbool)](https://pypi.org/project/envbool/)
31
+ [![CI](https://github.com/jkomalley/envbool/actions/workflows/ci.yml/badge.svg)](https://github.com/jkomalley/envbool/actions/workflows/ci.yml)
32
+
33
+ ---
34
+
35
+ Every project ends up with some version of this:
36
+
37
+ ```python
38
+ DEBUG = os.environ.get("DEBUG", "").lower() in ("1", "true", "yes")
39
+ VERBOSE = os.environ.get("VERBOSE", "").lower() in ("1", "true", "yes")
40
+ CACHE = os.environ.get("CACHE", "").lower() in ("1", "true", "yes")
41
+ ```
42
+
43
+ `envbool` replaces that:
44
+
45
+ ```python
46
+ from envbool import envbool
47
+
48
+ DEBUG = envbool("DEBUG")
49
+ VERBOSE = envbool("VERBOSE")
50
+ CACHE = envbool("CACHE")
51
+ ```
52
+
53
+ It also handles strict mode, warnings, custom value sets, config files, and a CLI for shell scripts.
54
+
55
+ ---
56
+
57
+ ## Installation
58
+
59
+ ```bash
60
+ pip install envbool
61
+ # or
62
+ uv add envbool
63
+ ```
64
+
65
+ ## Usage
66
+
67
+ **Lenient by default.** Anything not recognized as truthy returns `False`. Unset and empty variables return the default.
68
+
69
+ ```python
70
+ from envbool import envbool
71
+
72
+ DEBUG = envbool("DEBUG") # False if unset or empty
73
+ CACHE = envbool("CACHE", default=True) # True if unset or empty
74
+ ```
75
+
76
+ The built-in truthy values are `true`, `1`, `yes`, `on`. Comparison is case-insensitive.
77
+
78
+ **Strict mode** raises `InvalidBoolValueError` for unrecognized values — useful for catching typos in production config.
79
+
80
+ ```python
81
+ from envbool import envbool, InvalidBoolValueError
82
+
83
+ try:
84
+ USE_SSL = envbool("USE_SSL", strict=True)
85
+ except InvalidBoolValueError as e:
86
+ print(f"Bad value for USE_SSL: {e.value!r}")
87
+ sys.exit(1)
88
+ ```
89
+
90
+ **Extend the value sets** when your environment uses non-standard strings.
91
+
92
+ ```python
93
+ FEATURE = envbool("FEATURE_FLAG", extend_truthy={"enabled", "y"})
94
+ ```
95
+
96
+ **Coerce an arbitrary string** (not from `os.environ`) with `to_bool`:
97
+
98
+ ```python
99
+ from envbool import to_bool
100
+
101
+ to_bool("yes") # True
102
+ to_bool("0") # False
103
+ to_bool("maybe", strict=True) # raises InvalidBoolValueError
104
+ ```
105
+
106
+ ## CLI
107
+
108
+ The `envbool` command exits `0` for truthy and `1` for falsy, so it works naturally in shell scripts.
109
+
110
+ ```bash
111
+ # Control flow via exit code
112
+ envbool DEBUG && echo "debug is on"
113
+
114
+ # Print the resolved value
115
+ echo "Verbose: $(envbool --print VERBOSE)"
116
+
117
+ # Pipe a string
118
+ echo "yes" | envbool && echo "truthy"
119
+
120
+ # Strict mode
121
+ envbool --strict ENABLE_CACHE || echo "cache is off or misconfigured"
122
+ ```
123
+
124
+ ## Configuration
125
+
126
+ Put shared defaults in `envbool.toml` (or `[tool.envbool]` in `pyproject.toml`) at your project root:
127
+
128
+ ```toml
129
+ strict = true
130
+ extend_truthy = ["enabled"]
131
+ extend_falsy = ["disabled"]
132
+ ```
133
+
134
+ `envbool` walks up from the current directory to find the nearest config file, then falls back to a user-level config (`~/.config/envbool/config.toml` on Linux/macOS). Function arguments always override the config.
135
+
136
+ Set `ENVBOOL_NO_CONFIG=1` to skip config discovery entirely.
137
+
138
+ ## API
139
+
140
+ | Symbol | Description |
141
+ |---|---|
142
+ | `envbool(var, ...)` | Read an env var and return `bool` |
143
+ | `to_bool(value, ...)` | Coerce a string to `bool` |
144
+ | `load_config()` | Inspect the loaded config |
145
+ | `DEFAULT_TRUTHY` | `frozenset` of built-in truthy strings |
146
+ | `DEFAULT_FALSY` | `frozenset` of built-in falsy strings |
147
+ | `InvalidBoolValueError` | Raised in strict mode on unrecognized values |
148
+ | `ConfigError` | Raised when a config file is malformed |
149
+
150
+ Both `envbool()` and `to_bool()` accept the same keyword arguments: `default`, `strict`, `warn`, `truthy`, `falsy`, `extend_truthy`, `extend_falsy`. `truthy`/`falsy` replace the effective set; `extend_truthy`/`extend_falsy` add to it.
@@ -0,0 +1,12 @@
1
+ envbool/__init__.py,sha256=Liw1zzfhLJDkWcDZo8YwU2x50eoNBSAxKS-sX4PeW6U,1651
2
+ envbool/_cli.py,sha256=wct1z5OLw3wzoV8Cv-dOitbecV7zvpQAdT0xbqZZTV4,4261
3
+ envbool/_config.py,sha256=ir6Ev6j-UbEgDDa0oQxk1o9QsaqPu_X3KkwNxJflwNk,13606
4
+ envbool/_core.py,sha256=D6OgA8SyQoYZLTsuArmBo2ZWtF5Hvd408IgDVdLRd0I,6871
5
+ envbool/_defaults.py,sha256=2SQs08zrTXeNJ5jDTlD2E1sH4-we5nlzao_S7Y0lf6I,695
6
+ envbool/_env.py,sha256=UpHS-iFU92oa6Ij4ooknSUNlhHIehIbLtrBcgt4gyVQ,2299
7
+ envbool/exceptions.py,sha256=UTTbnsimjebr-91UrgUyipi8TK_WanS04s9fD7Mxrjw,1920
8
+ envbool/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ envbool-0.1.0.dist-info/WHEEL,sha256=WvwXFgRajeoYkfRVmDhkP4Qlqo31Mk687zIO2QQoFmw,80
10
+ envbool-0.1.0.dist-info/entry_points.txt,sha256=HlbOED6ApEXVINGBXP7iPQt6q1N-_2UUSJUVu83Y5tQ,47
11
+ envbool-0.1.0.dist-info/METADATA,sha256=UxVvXhSDR-_EUuL_mF4nL0dnAVV9D_hA73EA-Du60bE,4776
12
+ envbool-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.7
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ envbool = envbool._cli:main
3
+