Coverage for paperless/settings/parsers.py: 42%
127 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 09:07 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 09:07 +0000
1import copy
2import os
3from collections.abc import Callable
4from collections.abc import Mapping
5from pathlib import Path
6from typing import Any
7from typing import TypeVar
8from typing import overload
10T = TypeVar("T")
13def str_to_bool(value: str) -> bool:
14 """
15 Converts a string representation of truth to a boolean value.
17 Recognizes 'true', '1', 't', 'y', 'yes' as True, and
18 'false', '0', 'f', 'n', 'no' as False. Case-insensitive.
20 Args:
21 value: The string to convert.
23 Returns:
24 The boolean representation of the string.
26 Raises:
27 ValueError: If the string is not a recognized boolean value.
28 """
29 val_lower = value.strip().lower()
30 if val_lower in ("true", "1", "t", "y", "yes"):
31 return True
32 elif val_lower in ("false", "0", "f", "n", "no"): 32 ↛ 34line 32 didn't jump to line 34 because the condition on line 32 was always true
33 return False
34 raise ValueError(f"Cannot convert '{value}' to a boolean.")
37@overload
38def get_int_from_env(key: str) -> int | None: ... 38 ↛ anywhereline 38 didn't jump anywhere: it always raised an exception.
41@overload
42def get_int_from_env(key: str, default: None) -> int | None: ... 42 ↛ anywhereline 42 didn't jump anywhere: it always raised an exception.
45@overload
46def get_int_from_env(key: str, default: int) -> int: ... 46 ↛ anywhereline 46 didn't jump anywhere: it always raised an exception.
49def get_int_from_env(key: str, default: int | None = None) -> int | None:
50 """
51 Return an integer value based on the environment variable.
52 If default is provided, returns that value when key is missing.
53 If default is None, returns None when key is missing.
54 """
55 if key not in os.environ: 55 ↛ 58line 55 didn't jump to line 58 because the condition on line 55 was always true
56 return default
58 return int(os.environ[key])
61def parse_dict_from_str(
62 env_str: str | None,
63 defaults: dict[str, Any] | None = None,
64 type_map: Mapping[str, Callable[[str], Any]] | None = None,
65 separator: str = ",",
66) -> dict[str, Any]:
67 """
68 Parses a key-value string into a dictionary, applying defaults and casting types.
70 Supports nested keys via dot-notation, e.g.:
71 "database.host=localhost,database.port=5432"
73 Args:
74 env_str: The string from the environment variable (e.g., "port=9090,debug=true").
75 defaults: A dictionary of default values (can contain nested dicts).
76 type_map: A dictionary mapping keys (dot-notation allowed) to a type or a parsing
77 function (e.g., {'port': int, 'debug': bool, 'database.port': int}).
78 The special `bool` type triggers custom boolean parsing.
79 separator: The character used to separate key-value pairs. Defaults to ','.
81 Returns:
82 A dictionary with the parsed and correctly-typed settings.
84 Raises:
85 ValueError: If a value cannot be cast to its specified type.
86 """
88 def _set_nested(d: dict, keys: list[str], value: Any) -> None:
89 """Set a nested value, creating intermediate dicts as needed."""
90 cur = d
91 for k in keys[:-1]:
92 if k not in cur or not isinstance(cur[k], dict):
93 cur[k] = {}
94 cur = cur[k]
95 cur[keys[-1]] = value
97 def _get_nested(d: dict, keys: list[str]) -> Any:
98 """Get nested value or raise KeyError if not present."""
99 cur = d
100 for k in keys:
101 if not isinstance(cur, dict) or k not in cur:
102 raise KeyError
103 cur = cur[k]
104 return cur
106 def _has_nested(d: dict, keys: list[str]) -> bool:
107 try:
108 _get_nested(d, keys)
109 return True
110 except KeyError:
111 return False
113 settings: dict[str, Any] = copy.deepcopy(defaults) if defaults else {}
114 _type_map = type_map if type_map else {}
116 if not env_str: 116 ↛ 120line 116 didn't jump to line 120 because the condition on line 116 was always true
117 return settings
119 # Parse the environment string using the specified separator
120 pairs = [p.strip() for p in env_str.split(separator) if p.strip()]
121 for pair in pairs:
122 if "=" not in pair:
123 # ignore malformed pairs
124 continue
125 key, val = pair.split("=", 1)
126 key = key.strip()
127 val = val.strip()
128 if not key:
129 continue
130 parts = key.split(".")
131 _set_nested(settings, parts, val)
133 # Apply type casting to the updated settings (supports nested keys in type_map)
134 for key, caster in _type_map.items():
135 key_parts = key.split(".")
136 if _has_nested(settings, key_parts):
137 raw_val = _get_nested(settings, key_parts)
138 # Only cast if it's a string (i.e. from env parsing). If defaults already provided
139 # a different type we leave it as-is.
140 if isinstance(raw_val, str):
141 try:
142 if caster is bool:
143 parsed = str_to_bool(raw_val)
144 elif caster is Path:
145 parsed = Path(raw_val).resolve()
146 else:
147 parsed = caster(raw_val)
148 except (ValueError, TypeError) as e:
149 caster_name = getattr(caster, "__name__", repr(caster))
150 raise ValueError(
151 f"Error casting key '{key}' with value '{raw_val}' "
152 f"to type '{caster_name}'",
153 ) from e
154 _set_nested(settings, key_parts, parsed)
156 return settings
159def get_bool_from_env(key: str, default: str = "NO") -> bool:
160 """
161 Return a boolean value based on whatever the user has supplied in the
162 environment based on whether the value "looks like" it's True or not.
163 """
164 return str_to_bool(os.getenv(key, default))
167@overload
168def get_float_from_env(key: str) -> float | None: ... 168 ↛ anywhereline 168 didn't jump anywhere: it always raised an exception.
171@overload
172def get_float_from_env(key: str, default: None) -> float | None: ... 172 ↛ anywhereline 172 didn't jump anywhere: it always raised an exception.
175@overload
176def get_float_from_env(key: str, default: float) -> float: ... 176 ↛ anywhereline 176 didn't jump anywhere: it always raised an exception.
179def get_float_from_env(key: str, default: float | None = None) -> float | None:
180 """
181 Return a float value based on the environment variable.
182 If default is provided, returns that value when key is missing.
183 If default is None, returns None when key is missing.
184 """
185 if key not in os.environ: 185 ↛ 188line 185 didn't jump to line 188 because the condition on line 185 was always true
186 return default
188 return float(os.environ[key])
191@overload
192def get_path_from_env(key: str) -> Path | None: ... 192 ↛ anywhereline 192 didn't jump anywhere: it always raised an exception.
195@overload
196def get_path_from_env(key: str, default: None) -> Path | None: ... 196 ↛ anywhereline 196 didn't jump anywhere: it always raised an exception.
199@overload
200def get_path_from_env(key: str, default: Path | str) -> Path: ... 200 ↛ anywhereline 200 didn't jump anywhere: it always raised an exception.
203def get_path_from_env(key: str, default: Path | str | None = None) -> Path | None:
204 """
205 Return a Path object based on the environment variable.
206 If default is provided, returns that value when key is missing.
207 If default is None, returns None when key is missing.
208 """
209 if key not in os.environ: 209 ↛ 212line 209 didn't jump to line 212 because the condition on line 209 was always true
210 return default if default is None else Path(default).resolve()
212 return Path(os.environ[key]).resolve()
215def get_list_from_env(
216 key: str,
217 separator: str = ",",
218 default: list[T] | None = None,
219 *,
220 strip_whitespace: bool = True,
221 remove_empty: bool = True,
222 required: bool = False,
223) -> list[str] | list[T]:
224 """
225 Get and parse a list from an environment variable or return a default.
227 Args:
228 key: Environment variable name
229 separator: Character(s) to split on (default: ',')
230 default: Default value to return if env var is not set or empty
231 strip_whitespace: Whether to strip whitespace from each element
232 remove_empty: Whether to remove empty strings from the result
233 required: If True, raise an error when the env var is missing and no default provided
235 Returns:
236 List of strings or list of type-cast values, or default if env var is empty/None
238 Raises:
239 ValueError: If required=True and env var is missing and there is no default
240 """
241 # Get the environment variable value
242 env_value = os.environ.get(key)
244 # Handle required environment variables
245 if required and env_value is None and default is None: 245 ↛ 246line 245 didn't jump to line 246 because the condition on line 245 was never true
246 raise ValueError(f"Required environment variable '{key}' is not set")
248 if env_value: 248 ↛ 249line 248 didn't jump to line 249 because the condition on line 248 was never true
249 items = env_value.split(separator)
250 if strip_whitespace:
251 items = [item.strip() for item in items]
252 if remove_empty:
253 items = [item for item in items if item]
254 return items
255 elif default is not None:
256 return default
257 else:
258 return []
261@overload
262def get_choice_from_env( 262 ↛ anywhereline 262 didn't jump anywhere: it always raised an exception.
263 env_key: str,
264 choices: set[str] | frozenset[str],
265) -> str | None: ...
268@overload
269def get_choice_from_env( 269 ↛ anywhereline 269 didn't jump anywhere: it always raised an exception.
270 env_key: str,
271 choices: set[str] | frozenset[str],
272 default: None,
273) -> str | None: ...
276@overload
277def get_choice_from_env( 277 ↛ anywhereline 277 didn't jump anywhere: it always raised an exception.
278 env_key: str,
279 choices: set[str] | frozenset[str],
280 default: str,
281) -> str: ...
284def get_choice_from_env(
285 env_key: str,
286 choices: set[str] | frozenset[str],
287 default: str | None = None,
288) -> str | None:
289 """
290 Gets and validates an environment variable against a set of allowed choices.
292 Args:
293 env_key: The environment variable key to validate
294 choices: Set of valid choices for the environment variable
295 default: Default value if environment variable is not set; None means optional
297 Returns:
298 The validated environment variable value, or None if not set and no default
300 Raises:
301 ValueError: If the environment variable value is not in choices
302 """
303 value = os.environ.get(env_key, default)
305 if value is None:
306 return None
308 if value not in choices: 308 ↛ 309line 308 didn't jump to line 309 because the condition on line 308 was never true
309 raise ValueError(
310 f"Environment variable '{env_key}' has invalid value '{value}'. "
311 f"Valid choices are: {', '.join(sorted(choices))}",
312 )
314 return value