Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/pip/_internal/configuration.py: 27%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

198 statements  

1"""Configuration management setup 

2 

3Some terminology: 

4- name 

5 As written in config files. 

6- value 

7 Value associated with a name 

8- key 

9 Name combined with it's section (section.name) 

10- variant 

11 A single word describing where the configuration key-value pair came from 

12""" 

13 

14from __future__ import annotations 

15 

16import configparser 

17import os 

18import sys 

19from collections.abc import Iterable 

20from enum import Enum 

21from typing import Any 

22 

23from pip._internal.exceptions import ( 

24 ConfigurationError, 

25 ConfigurationFileCouldNotBeLoaded, 

26) 

27from pip._internal.utils import appdirs 

28from pip._internal.utils._log import getLogger 

29from pip._internal.utils.compat import WINDOWS, get_locale_encoding 

30from pip._internal.utils.misc import ensure_dir 

31 

32RawConfigParser = configparser.RawConfigParser # Shorthand 

33 

34CONFIG_BASENAME = "pip.ini" if WINDOWS else "pip.conf" 

35ENV_NAMES_IGNORED = "version", "help" 

36 

37 

38# The kinds of configurations there are. 

39# TODO: Use StrEnum when Python 3.10 support is dropped. 

40class Kind(str, Enum): 

41 """Configuration sources, including files and environment variables.""" 

42 

43 USER = "user" # User Specific 

44 GLOBAL = "global" # System Wide 

45 SITE = "site" # [Virtual] Environment Specific 

46 ENV = "env" # from PIP_CONFIG_FILE 

47 ENV_VAR = "env-var" # from Environment Variables 

48 

49 __str__ = str.__str__ 

50 __repr__ = str.__repr__ 

51 

52 

53OVERRIDE_ORDER = Kind.GLOBAL, Kind.USER, Kind.SITE, Kind.ENV, Kind.ENV_VAR 

54VALID_LOAD_ONLY = Kind.USER, Kind.GLOBAL, Kind.SITE 

55 

56logger = getLogger(__name__) 

57 

58 

59# NOTE: Maybe use the optionx attribute to normalize keynames. 

60def _normalize_name(name: str) -> str: 

61 """Make a name consistent regardless of source (environment or file)""" 

62 name = name.lower().replace("_", "-") 

63 name = name.removeprefix("--") # only prefer long opts 

64 return name 

65 

66 

67def _disassemble_key(name: str) -> list[str]: 

68 if "." not in name: 

69 error_message = ( 

70 "Key does not contain dot separated section and key. " 

71 f"Perhaps you wanted to use 'global.{name}' instead?" 

72 ) 

73 raise ConfigurationError(error_message) 

74 return name.split(".", 1) 

75 

76 

77def get_configuration_files() -> dict[Kind, list[str]]: 

78 global_config_files = [ 

79 os.path.join(path, CONFIG_BASENAME) for path in appdirs.site_config_dirs("pip") 

80 ] 

81 

82 site_config_file = os.path.join(sys.prefix, CONFIG_BASENAME) 

83 legacy_config_file = os.path.join( 

84 os.path.expanduser("~"), 

85 "pip" if WINDOWS else ".pip", 

86 CONFIG_BASENAME, 

87 ) 

88 new_config_file = os.path.join(appdirs.user_config_dir("pip"), CONFIG_BASENAME) 

89 return { 

90 Kind.GLOBAL: global_config_files, 

91 Kind.SITE: [site_config_file], 

92 Kind.USER: [legacy_config_file, new_config_file], 

93 } 

94 

95 

96class Configuration: 

97 """Handles management of configuration. 

98 

99 Provides an interface to accessing and managing configuration files. 

100 

101 This class converts provides an API that takes "section.key-name" style 

102 keys and stores the value associated with it as "key-name" under the 

103 section "section". 

104 

105 This allows for a clean interface wherein the both the section and the 

106 key-name are preserved in an easy to manage form in the configuration files 

107 and the data stored is also nice. 

108 """ 

109 

110 def __init__(self, isolated: bool, load_only: Kind | None = None) -> None: 

111 super().__init__() 

112 

113 if load_only is not None and load_only not in VALID_LOAD_ONLY: 

114 raise ConfigurationError( 

115 "Got invalid value for load_only - should be one of {}".format( 

116 ", ".join(map(repr, VALID_LOAD_ONLY)) 

117 ) 

118 ) 

119 self.isolated = isolated 

120 self.load_only = load_only 

121 

122 # Because we keep track of where we got the data from 

123 self._parsers: dict[Kind, list[tuple[str, RawConfigParser]]] = { 

124 variant: [] for variant in OVERRIDE_ORDER 

125 } 

126 self._config: dict[Kind, dict[str, dict[str, Any]]] = { 

127 variant: {} for variant in OVERRIDE_ORDER 

128 } 

129 self._modified_parsers: list[tuple[str, RawConfigParser]] = [] 

130 

131 def load(self) -> None: 

132 """Loads configuration from configuration files and environment""" 

133 self._load_config_files() 

134 if not self.isolated: 

135 self._load_environment_vars() 

136 

137 def get_file_to_edit(self) -> str | None: 

138 """Returns the file with highest priority in configuration""" 

139 assert self.load_only is not None, "Need to be specified a file to be editing" 

140 

141 try: 

142 return self._get_parser_to_modify()[0] 

143 except IndexError: 

144 return None 

145 

146 def items(self) -> Iterable[tuple[str, Any]]: 

147 """Returns key-value pairs like dict.items() representing the loaded 

148 configuration 

149 """ 

150 return self._dictionary.items() 

151 

152 def get_value(self, key: str) -> Any: 

153 """Get a value from the configuration.""" 

154 orig_key = key 

155 key = _normalize_name(key) 

156 try: 

157 clean_config: dict[str, Any] = {} 

158 for file_values in self._dictionary.values(): 

159 clean_config.update(file_values) 

160 return clean_config[key] 

161 except KeyError: 

162 # disassembling triggers a more useful error message than simply 

163 # "No such key" in the case that the key isn't in the form command.option 

164 _disassemble_key(key) 

165 raise ConfigurationError(f"No such key - {orig_key}") 

166 

167 def set_value(self, key: str, value: Any) -> None: 

168 """Modify a value in the configuration.""" 

169 key = _normalize_name(key) 

170 self._ensure_have_load_only() 

171 

172 assert self.load_only 

173 fname, parser = self._get_parser_to_modify() 

174 

175 if parser is not None: 

176 section, name = _disassemble_key(key) 

177 

178 # Modify the parser and the configuration 

179 if not parser.has_section(section): 

180 parser.add_section(section) 

181 parser.set(section, name, value) 

182 

183 self._config[self.load_only].setdefault(fname, {}) 

184 self._config[self.load_only][fname][key] = value 

185 self._mark_as_modified(fname, parser) 

186 

187 def unset_value(self, key: str) -> None: 

188 """Unset a value in the configuration.""" 

189 orig_key = key 

190 key = _normalize_name(key) 

191 self._ensure_have_load_only() 

192 

193 assert self.load_only 

194 fname, parser = self._get_parser_to_modify() 

195 

196 if ( 

197 key not in self._config[self.load_only][fname] 

198 and key not in self._config[self.load_only] 

199 ): 

200 raise ConfigurationError(f"No such key - {orig_key}") 

201 

202 if parser is not None: 

203 section, name = _disassemble_key(key) 

204 if not ( 

205 parser.has_section(section) and parser.remove_option(section, name) 

206 ): 

207 # The option was not removed. 

208 raise ConfigurationError( 

209 "Fatal Internal error [id=1]. Please report as a bug." 

210 ) 

211 

212 # The section may be empty after the option was removed. 

213 if not parser.items(section): 

214 parser.remove_section(section) 

215 self._mark_as_modified(fname, parser) 

216 try: 

217 del self._config[self.load_only][fname][key] 

218 except KeyError: 

219 del self._config[self.load_only][key] 

220 

221 def save(self) -> None: 

222 """Save the current in-memory state.""" 

223 self._ensure_have_load_only() 

224 

225 for fname, parser in self._modified_parsers: 

226 logger.info("Writing to %s", fname) 

227 

228 # Ensure directory exists. 

229 ensure_dir(os.path.dirname(fname)) 

230 

231 # Ensure directory's permission(need to be writeable) 

232 try: 

233 with open(fname, "w") as f: 

234 parser.write(f) 

235 except OSError as error: 

236 raise ConfigurationError( 

237 f"An error occurred while writing to the configuration file " 

238 f"{fname}: {error}" 

239 ) 

240 

241 # 

242 # Private routines 

243 # 

244 

245 def _ensure_have_load_only(self) -> None: 

246 if self.load_only is None: 

247 raise ConfigurationError("Needed a specific file to be modifying.") 

248 logger.debug("Will be working with %s variant only", self.load_only) 

249 

250 @property 

251 def _dictionary(self) -> dict[str, dict[str, Any]]: 

252 """A dictionary representing the loaded configuration.""" 

253 # NOTE: Dictionaries are not populated if not loaded. So, conditionals 

254 # are not needed here. 

255 retval = {} 

256 

257 for variant in OVERRIDE_ORDER: 

258 retval.update(self._config[variant]) 

259 

260 return retval 

261 

262 def _load_config_files(self) -> None: 

263 """Loads configuration from configuration files""" 

264 config_files = dict(self.iter_config_files()) 

265 if config_files[Kind.ENV][0:1] == [os.devnull]: 

266 logger.debug( 

267 "Skipping loading configuration files due to " 

268 "environment's PIP_CONFIG_FILE being os.devnull" 

269 ) 

270 return 

271 

272 for variant, files in config_files.items(): 

273 for fname in files: 

274 # If there's specific variant set in `load_only`, load only 

275 # that variant, not the others. 

276 if self.load_only is not None and variant != self.load_only: 

277 logger.debug("Skipping file '%s' (variant: %s)", fname, variant) 

278 continue 

279 

280 parser = self._load_file(variant, fname) 

281 

282 # Keeping track of the parsers used 

283 self._parsers[variant].append((fname, parser)) 

284 

285 def _load_file(self, variant: Kind, fname: str) -> RawConfigParser: 

286 logger.verbose("For variant '%s', will try loading '%s'", variant, fname) 

287 parser = self._construct_parser(fname) 

288 

289 for section in parser.sections(): 

290 items = parser.items(section) 

291 self._config[variant].setdefault(fname, {}) 

292 self._config[variant][fname].update(self._normalized_keys(section, items)) 

293 

294 return parser 

295 

296 def _construct_parser(self, fname: str) -> RawConfigParser: 

297 parser = configparser.RawConfigParser() 

298 # If there is no such file, don't bother reading it but create the 

299 # parser anyway, to hold the data. 

300 # Doing this is useful when modifying and saving files, where we don't 

301 # need to construct a parser. 

302 if os.path.exists(fname): 

303 locale_encoding = get_locale_encoding() 

304 try: 

305 parser.read(fname, encoding=locale_encoding) 

306 except UnicodeDecodeError: 

307 # See https://github.com/pypa/pip/issues/4963 

308 raise ConfigurationFileCouldNotBeLoaded( 

309 reason=f"contains invalid {locale_encoding} characters", 

310 fname=fname, 

311 ) 

312 except configparser.Error as error: 

313 # See https://github.com/pypa/pip/issues/4893 

314 raise ConfigurationFileCouldNotBeLoaded(error=error) 

315 return parser 

316 

317 def _load_environment_vars(self) -> None: 

318 """Loads configuration from environment variables""" 

319 self._config[Kind.ENV_VAR].setdefault(":env:", {}) 

320 self._config[Kind.ENV_VAR][":env:"].update( 

321 self._normalized_keys(":env:", self.get_environ_vars()) 

322 ) 

323 

324 def _normalized_keys( 

325 self, section: str, items: Iterable[tuple[str, Any]] 

326 ) -> dict[str, Any]: 

327 """Normalizes items to construct a dictionary with normalized keys. 

328 

329 This routine is where the names become keys and are made the same 

330 regardless of source - configuration files or environment. 

331 """ 

332 normalized = {} 

333 for name, val in items: 

334 key = section + "." + _normalize_name(name) 

335 normalized[key] = val 

336 return normalized 

337 

338 def get_environ_vars(self) -> Iterable[tuple[str, str]]: 

339 """Returns a generator with all environmental vars with prefix PIP_""" 

340 for key, val in os.environ.items(): 

341 if key.startswith("PIP_"): 

342 name = key[4:].lower() 

343 if name not in ENV_NAMES_IGNORED: 

344 yield name, val 

345 

346 # XXX: This is patched in the tests. 

347 def iter_config_files(self) -> Iterable[tuple[Kind, list[str]]]: 

348 """Yields variant and configuration files associated with it. 

349 

350 This should be treated like items of a dictionary. The order 

351 here doesn't affect what gets overridden. That is controlled 

352 by OVERRIDE_ORDER. However this does control the order they are 

353 displayed to the user. It's probably most ergonomic to display 

354 things in the same order as OVERRIDE_ORDER 

355 """ 

356 # SMELL: Move the conditions out of this function 

357 

358 env_config_file = os.environ.get("PIP_CONFIG_FILE", None) 

359 config_files = get_configuration_files() 

360 

361 yield Kind.GLOBAL, config_files[Kind.GLOBAL] 

362 

363 # per-user config is not loaded when env_config_file exists 

364 should_load_user_config = not self.isolated and not ( 

365 env_config_file and os.path.exists(env_config_file) 

366 ) 

367 if should_load_user_config: 

368 # The legacy config file is overridden by the new config file 

369 yield Kind.USER, config_files[Kind.USER] 

370 

371 # virtualenv config 

372 yield Kind.SITE, config_files[Kind.SITE] 

373 

374 if env_config_file is not None: 

375 yield Kind.ENV, [env_config_file] 

376 else: 

377 yield Kind.ENV, [] 

378 

379 def get_values_in_config(self, variant: Kind) -> dict[str, Any]: 

380 """Get values present in a config file""" 

381 return self._config[variant] 

382 

383 def _get_parser_to_modify(self) -> tuple[str, RawConfigParser]: 

384 # Determine which parser to modify 

385 assert self.load_only 

386 parsers = self._parsers[self.load_only] 

387 if not parsers: 

388 # This should not happen if everything works correctly. 

389 raise ConfigurationError( 

390 "Fatal Internal error [id=2]. Please report as a bug." 

391 ) 

392 

393 # Use the highest priority parser. 

394 return parsers[-1] 

395 

396 # XXX: This is patched in the tests. 

397 def _mark_as_modified(self, fname: str, parser: RawConfigParser) -> None: 

398 file_parser_tuple = (fname, parser) 

399 if file_parser_tuple not in self._modified_parsers: 

400 self._modified_parsers.append(file_parser_tuple) 

401 

402 def __repr__(self) -> str: 

403 return f"{self.__class__.__name__}({self._dictionary!r})"