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

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 

9 

10T = TypeVar("T") 

11 

12 

13def str_to_bool(value: str) -> bool: 

14 """ 

15 Converts a string representation of truth to a boolean value. 

16 

17 Recognizes 'true', '1', 't', 'y', 'yes' as True, and 

18 'false', '0', 'f', 'n', 'no' as False. Case-insensitive. 

19 

20 Args: 

21 value: The string to convert. 

22 

23 Returns: 

24 The boolean representation of the string. 

25 

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.") 

35 

36 

37@overload 

38def get_int_from_env(key: str) -> int | None: ... 38 ↛ anywhereline 38 didn't jump anywhere: it always raised an exception.

39 

40 

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.

43 

44 

45@overload 

46def get_int_from_env(key: str, default: int) -> int: ... 46 ↛ anywhereline 46 didn't jump anywhere: it always raised an exception.

47 

48 

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 

57 

58 return int(os.environ[key]) 

59 

60 

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. 

69 

70 Supports nested keys via dot-notation, e.g.: 

71 "database.host=localhost,database.port=5432" 

72 

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 ','. 

80 

81 Returns: 

82 A dictionary with the parsed and correctly-typed settings. 

83 

84 Raises: 

85 ValueError: If a value cannot be cast to its specified type. 

86 """ 

87 

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 

96 

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 

105 

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 

112 

113 settings: dict[str, Any] = copy.deepcopy(defaults) if defaults else {} 

114 _type_map = type_map if type_map else {} 

115 

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 

118 

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) 

132 

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) 

155 

156 return settings 

157 

158 

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)) 

165 

166 

167@overload 

168def get_float_from_env(key: str) -> float | None: ... 168 ↛ anywhereline 168 didn't jump anywhere: it always raised an exception.

169 

170 

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.

173 

174 

175@overload 

176def get_float_from_env(key: str, default: float) -> float: ... 176 ↛ anywhereline 176 didn't jump anywhere: it always raised an exception.

177 

178 

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 

187 

188 return float(os.environ[key]) 

189 

190 

191@overload 

192def get_path_from_env(key: str) -> Path | None: ... 192 ↛ anywhereline 192 didn't jump anywhere: it always raised an exception.

193 

194 

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.

197 

198 

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.

201 

202 

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() 

211 

212 return Path(os.environ[key]).resolve() 

213 

214 

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. 

226 

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 

234 

235 Returns: 

236 List of strings or list of type-cast values, or default if env var is empty/None 

237 

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) 

243 

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") 

247 

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 [] 

259 

260 

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: ... 

266 

267 

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: ... 

274 

275 

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: ... 

282 

283 

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. 

291 

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 

296 

297 Returns: 

298 The validated environment variable value, or None if not set and no default 

299 

300 Raises: 

301 ValueError: If the environment variable value is not in choices 

302 """ 

303 value = os.environ.get(env_key, default) 

304 

305 if value is None: 

306 return None 

307 

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 ) 

313 

314 return value