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 +42 -0
- envbool/_cli.py +121 -0
- envbool/_config.py +374 -0
- envbool/_core.py +177 -0
- envbool/_defaults.py +14 -0
- envbool/_env.py +65 -0
- envbool/exceptions.py +55 -0
- envbool/py.typed +0 -0
- envbool-0.1.0.dist-info/METADATA +150 -0
- envbool-0.1.0.dist-info/RECORD +12 -0
- envbool-0.1.0.dist-info/WHEEL +4 -0
- envbool-0.1.0.dist-info/entry_points.txt +3 -0
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
|
+
[](https://pypi.org/project/envbool/)
|
|
29
|
+
[](https://pypi.org/project/envbool/)
|
|
30
|
+
[](https://pypi.org/project/envbool/)
|
|
31
|
+
[](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,,
|