Coverage for .tox/cov/lib/python3.11/site-packages/confattr/config.py: 100%

260 statements  

« prev     ^ index     » next       coverage.py v7.4.0, created at 2024-01-19 16:34 +0100

1#!./runmodule.sh 

2 

3import builtins 

4import enum 

5import typing 

6from collections.abc import Iterable, Iterator, Container, Sequence, Mapping, Callable 

7 

8if typing.TYPE_CHECKING: 

9 from typing_extensions import Self 

10 

11from .formatters import AbstractFormatter, CopyableAbstractFormatter, Primitive, List, Set, Dict, format_primitive_value 

12from . import state 

13 

14 

15#: An identifier to specify which value of a :class:`~confattr.config.MultiConfig` or :class:`~confattr.config.MultiDictConfig` should be used for a certain object. 

16ConfigId = typing.NewType('ConfigId', str) 

17 

18T_KEY = typing.TypeVar('T_KEY') 

19T = typing.TypeVar('T') 

20 

21class TimingError(Exception): 

22 

23 ''' 

24 Is raised when trying to instantiate :class:`~confattr.config.Config` if a :class:`~confattr.configfile.ConfigFile` has been instantiated before without passing an explicit list or set to :paramref:`~confattr.configfile.ConfigFile.config_instances` or when trying to change a :attr:`~confattr.config.Config.key` after creating any :class:`~confattr.configfile.ConfigFile` because such changes would otherwise be silently ignored by that :class:`~confattr.configfile.ConfigFile`. 

25 ''' 

26 

27 

28class Config(typing.Generic[T]): 

29 

30 ''' 

31 Each instance of this class represents a setting which can be changed in a config file. 

32 

33 This class implements the `descriptor protocol <https://docs.python.org/3/reference/datamodel.html#implementing-descriptors>`__ to return :attr:`~confattr.config.Config.value` if an instance of this class is accessed as an instance attribute. 

34 If you want to get this object you need to access it as a class attribute. 

35 ''' 

36 

37 #: A mapping of all :class:`~confattr.config.Config` instances. The key in the mapping is the :attr:`~confattr.config.Config.key` attribute. The value is the :class:`~confattr.config.Config` instance. New :class:`~confattr.config.Config` instances add themselves automatically in their constructor. 

38 instances: 'dict[str, Config[typing.Any]]' = {} 

39 

40 @classmethod 

41 def iter_instances(cls) -> 'Iterator[Config[typing.Any]|DictConfig[typing.Any, typing.Any]]': 

42 ''' 

43 Yield the instances in :attr:`~confattr.config.Config.instances` but merge :class:`~confattr.config.DictConfig` items to a single :class:`~confattr.config.DictConfig` instance so that they can be sorted differently. 

44 ''' 

45 parents = set() 

46 for cfg in cls.instances.values(): 

47 if cfg.parent: 

48 if cfg.parent not in parents: 

49 yield cfg.parent 

50 parents.add(cfg.parent) 

51 else: 

52 yield cfg 

53 

54 default_config_id = ConfigId('general') 

55 

56 #: The value of this setting. 

57 value: 'T' 

58 

59 #: Information about data type, unit and allowed values for :attr:`~confattr.config.Config.value` and methods how to parse, format and complete it. 

60 type: 'AbstractFormatter[T]' 

61 

62 #: A description of this setting or a description for each allowed value. 

63 help: 'str|dict[T, str]|None' 

64 

65 

66 _key_changer: 'list[Callable[[str], str]]' = [] 

67 

68 @classmethod 

69 def push_key_changer(cls, callback: 'Callable[[str], str]') -> None: 

70 ''' 

71 Modify the key of all settings which will be defined after calling this method. 

72 

73 Call this before an ``import`` and :meth:`~confattr.config.Config.pop_key_changer` after it if you are unhappy with the keys of a third party library. 

74 If you import that library in different modules make sure you do this at the import which is executed first. 

75 

76 :param callback: A function which takes the key as argument and returns the modified key. 

77 ''' 

78 cls._key_changer.append(callback) 

79 

80 @classmethod 

81 def pop_key_changer(cls) -> 'Callable[[str], str]': 

82 ''' 

83 Undo the last call to :meth:`~confattr.config.Config.push_key_changer`. 

84 ''' 

85 return cls._key_changer.pop() 

86 

87 

88 def __init__(self, 

89 key: str, 

90 default: T, *, 

91 type: 'AbstractFormatter[T]|None' = None, 

92 unit: 'str|None' = None, 

93 allowed_values: 'Sequence[T]|dict[str, T]|None' = None, 

94 help: 'str|dict[T, str]|None' = None, 

95 parent: 'DictConfig[typing.Any, T]|None' = None, 

96 ): 

97 ''' 

98 :param key: The name of this setting in the config file 

99 :param default: The default value of this setting 

100 :param type: How to parse, format and complete a value. Usually this is determined automatically based on :paramref:`~confattr.config.Config.default`. But if :paramref:`~confattr.config.Config.default` is an empty list the item type cannot be determined automatically so that this argument must be passed explicitly. This also gives the possibility to format a standard type differently e.g. as :class:`~confattr.formatters.Hex`. It is not permissible to reuse the same object for different settings, otherwise :meth:`AbstractFormatter.set_config_key() <confattr.formatters.AbstractFormatter.set_config_key>` will throw an exception. 

101 :param unit: The unit of an int or float value (only if type is None) 

102 :param allowed_values: The possible values this setting can have. Values read from a config file or an environment variable are checked against this. The :paramref:`~confattr.config.Config.default` value is *not* checked. (Only if type is None.) 

103 :param help: A description of this setting 

104 :param parent: Applies only if this is part of a :class:`~confattr.config.DictConfig` 

105 

106 :obj:`~confattr.config.T` can be one of: 

107 * :class:`str` 

108 * :class:`int` 

109 * :class:`float` 

110 * :class:`bool` 

111 * a subclass of :class:`enum.Enum` (the value used in the config file is the name in lower case letters with hyphens instead of underscores) 

112 * a class where :meth:`~object.__str__` returns a string representation which can be passed to the constructor to create an equal object. \ 

113 A help which is written to the config file must be provided as a str in the class attribute :attr:`~confattr.types.AbstractType.help` or by adding it to :attr:`Primitive.help_dict <confattr.formatters.Primitive.help_dict>`. \ 

114 If that class has a str attribute :attr:`~confattr.types.AbstractType.type_name` this is used instead of the class name inside of config file. 

115 * a :class:`list` of any of the afore mentioned data types. The list may not be empty when it is passed to this constructor so that the item type can be derived but it can be emptied immediately afterwards. (The type of the items is not dynamically enforced—that's the job of a static type checker—but the type is mentioned in the help.) 

116 

117 :raises ValueError: if key is not unique 

118 :raises TypeError: if :paramref:`~confattr.config.Config.default` is an empty list/set because the first element is used to infer the data type to which a value given in a config file is converted 

119 :raises TypeError: if this setting is a number or a list of numbers and :paramref:`~confattr.config.Config.unit` is not given 

120 :raises TimingError: if this setting is defined after creating a :class:`~confattr.configfile.ConfigFile` object without passing a list or set of settings to :paramref:`~confattr.configfile.ConfigFile.config_instances` 

121 ''' 

122 if state.has_config_file_been_instantiated: 

123 raise TimingError("The setting %r is defined after instantiating a ConfigFile. It will not be available in the ConfigFile. If this is intentional you can avoid this Exception by explicitly passing a set or list of settings to config_instances of the ConfigFile." % key) 

124 if self._key_changer: 

125 key = self._key_changer[-1](key) 

126 

127 if type is None: 

128 if isinstance(default, list): 

129 if not default: 

130 raise TypeError('I cannot infer the item type from an empty list. Please pass an argument to the type parameter.') 

131 item_type: 'builtins.type[T]' = builtins.type(default[0]) 

132 type = typing.cast('AbstractFormatter[T]', List(item_type=Primitive(item_type, allowed_values=allowed_values, unit=unit))) 

133 elif isinstance(default, set): 

134 if not default: 

135 raise TypeError('I cannot infer the item type from an empty set. Please pass an argument to the type parameter.') 

136 item_type = builtins.type(next(iter(default))) 

137 type = typing.cast('AbstractFormatter[T]', Set(item_type=Primitive(item_type, allowed_values=allowed_values, unit=unit))) 

138 elif isinstance(default, dict): 

139 if not default: 

140 raise TypeError('I cannot infer the key and value types from an empty dict. Please pass an argument to the type parameter.') 

141 some_key, some_value = next(iter(default.items())) 

142 key_type = Primitive(builtins.type(some_key)) 

143 val_type = Primitive(builtins.type(some_value), allowed_values=allowed_values, unit=unit) 

144 type = typing.cast('AbstractFormatter[T]', Dict(key_type, val_type)) 

145 else: 

146 type = Primitive(builtins.type(default), allowed_values=allowed_values, unit=unit) 

147 else: 

148 if unit is not None: 

149 raise TypeError("The keyword argument 'unit' is not supported if 'type' is given. Pass it to the type instead.") 

150 if allowed_values is not None: 

151 raise TypeError("The keyword argument 'allowed_values' is not supported if 'type' is given. Pass it to the type instead.") 

152 

153 type.set_config_key(key) 

154 

155 self._key = key 

156 self.value = default 

157 self.type = type 

158 self.help = help 

159 self.parent = parent 

160 

161 cls = builtins.type(self) 

162 if key in cls.instances: 

163 raise ValueError(f'duplicate config key {key!r}') 

164 cls.instances[key] = self 

165 

166 @property 

167 def key(self) -> str: 

168 ''' 

169 The name of this setting which is used in the config file. 

170 This must be unique. 

171 You can change this attribute but only as long as no :class:`~confattr.configfile.ConfigFile` or :class:`~confattr.quickstart.ConfigManager` has been instantiated. 

172 ''' 

173 return self._key 

174 

175 @key.setter 

176 def key(self, key: str) -> None: 

177 if state.has_any_config_file_been_instantiated: 

178 raise TimingError('ConfigFile has been instantiated already. Changing a key now would go unnoticed by that ConfigFile.') 

179 if key in self.instances: 

180 raise ValueError(f'duplicate config key {key!r}') 

181 del self.instances[self._key] 

182 self._key = key 

183 self.type.config_key = key 

184 self.instances[key] = self 

185 

186 

187 @typing.overload 

188 def __get__(self, instance: None, owner: typing.Any = None) -> 'Self': 

189 pass 

190 

191 @typing.overload 

192 def __get__(self, instance: typing.Any, owner: typing.Any = None) -> T: 

193 pass 

194 

195 def __get__(self, instance: typing.Any, owner: typing.Any = None) -> 'T|Self': 

196 if instance is None: 

197 return self 

198 

199 return self.value 

200 

201 def __set__(self: 'Config[T]', instance: typing.Any, value: T) -> None: 

202 self.value = value 

203 

204 def __repr__(self) -> str: 

205 return '%s(%s, ...)' % (type(self).__name__, ', '.join(repr(a) for a in (self.key, self.value))) 

206 

207 def set_value(self: 'Config[T]', config_id: 'ConfigId|None', value: T) -> None: 

208 ''' 

209 This method is just to provide a common interface for :class:`~confattr.config.Config` and :class:`~confattr.config.MultiConfig`. 

210 If you know that you are dealing with a normal :class:`~confattr.config.Config` you can set :attr:`~confattr.config.Config.value` directly. 

211 ''' 

212 if config_id is None: 

213 config_id = self.default_config_id 

214 if config_id != self.default_config_id: 

215 raise ValueError(f'{self.key} cannot be set for specific groups, config_id must be the default {self.default_config_id!r} not {config_id!r}') 

216 self.value = value 

217 

218 def wants_to_be_exported(self) -> bool: 

219 return True 

220 

221 def get_value(self, config_id: 'ConfigId|None') -> T: 

222 ''' 

223 :return: :attr:`~confattr.config.Config.value` 

224 

225 This getter is only to have a common interface for :class:`~confattr.config.Config` and :class:`~confattr.config.MultiConfig` 

226 ''' 

227 return self.value 

228 

229 def is_value_valid(self) -> bool: 

230 ''' 

231 :return: true unless the value of an :class:`~confattr.config.ExplicitConfig` instance has not been set yet 

232 ''' 

233 return True 

234 

235 

236class ExplicitConfig(Config[T]): 

237 

238 ''' 

239 A setting without a default value which requires the user to explicitly set a value in the config file or pass it as command line argument. 

240 

241 You can use :meth:`~confattr.config.ExplicitConfig.is_value_valid` in order to check whether this config has a value or not. 

242 

243 If you try to use the value before it has been set: If you try to access the config as instance attribute (:python:`object.config`) a :class:`TypeError` is thrown. Otherwise (:python:`config.value`) :obj:`None` is returned. 

244 ''' 

245 

246 def __init__(self, 

247 key: str, 

248 type: 'AbstractFormatter[T]|type[T]|None' = None, *, 

249 unit: 'str|None' = None, 

250 allowed_values: 'Sequence[T]|dict[str, T]|None' = None, 

251 help: 'str|dict[T, str]|None' = None, 

252 parent: 'DictConfig[typing.Any, T]|None' = None, 

253 ): 

254 ''' 

255 :param key: The name of this setting in the config file 

256 :param type: How to parse, format and complete a value. Any class which can be passed to :class:`~confattr.formatters.Primitive` or an object of a subclass of :class:`~confattr.formatters.AbstractFormatter`. 

257 :param unit: The unit of an int or float value (only if type is not an :class:`~confattr.formatters.AbstractFormatter`) 

258 :param allowed_values: The possible values this setting can have. Values read from a config file or an environment variable are checked against this. The :paramref:`~confattr.config.Config.default` value is *not* checked. (Only if type is not an :class:`~confattr.formatters.AbstractFormatter`.) 

259 :param help: A description of this setting 

260 :param parent: Applies only if this is part of a :class:`~confattr.config.DictConfig` 

261 ''' 

262 if type is None: 

263 if not allowed_values: 

264 raise TypeError("missing required positional argument: 'type'") 

265 elif isinstance(allowed_values, dict): 

266 type = builtins.type(tuple(allowed_values.values())[0]) 

267 else: 

268 type = builtins.type(allowed_values[0]) 

269 if not isinstance(type, AbstractFormatter): 

270 type = Primitive(type, unit=unit, allowed_values=allowed_values) 

271 super().__init__(key, 

272 default = None, # type: ignore [arg-type] 

273 type = type, 

274 help = help, 

275 parent = parent, 

276 ) 

277 

278 @typing.overload 

279 def __get__(self, instance: None, owner: typing.Any = None) -> 'Self': 

280 pass 

281 

282 @typing.overload 

283 def __get__(self, instance: typing.Any, owner: typing.Any = None) -> T: 

284 pass 

285 

286 def __get__(self, instance: typing.Any, owner: typing.Any = None) -> 'T|Self': 

287 if instance is None: 

288 return self 

289 

290 if self.value is None: 

291 raise TypeError(f"value for {self.key!r} has not been set") 

292 return self.value 

293 

294 def is_value_valid(self) -> bool: 

295 return self.value is not None 

296 

297 

298class DictConfig(typing.Generic[T_KEY, T]): 

299 

300 ''' 

301 A container for several settings which belong together. 

302 Except for :meth:`~object.__eq__` and :meth:`~object.__ne__` it behaves like a normal :class:`~collections.abc.Mapping` 

303 but internally the items are stored in :class:`~confattr.config.Config` instances. 

304 

305 In contrast to a :class:`~confattr.config.Config` instance it does *not* make a difference whether an instance of this class is accessed as a type or instance attribute. 

306 ''' 

307 

308 class Sort(enum.Enum): 

309 NAME = enum.auto() 

310 ENUM_VALUE = enum.auto() 

311 NONE = enum.auto() 

312 

313 def __init__(self, 

314 key_prefix: str, 

315 default_values: 'dict[T_KEY, T]', *, 

316 type: 'CopyableAbstractFormatter[T]|None' = None, 

317 ignore_keys: 'Container[T_KEY]' = set(), 

318 unit: 'str|None' = None, 

319 allowed_values: 'Sequence[T]|dict[str, T]|None' = None, 

320 help: 'str|None' = None, 

321 sort: Sort = Sort.NAME, 

322 ) -> None: 

323 ''' 

324 :param key_prefix: A common prefix which is used by :meth:`~confattr.config.DictConfig.format_key` to generate the :attr:`~confattr.config.Config.key` by which the setting is identified in the config file 

325 :param default_values: The content of this container. A :class:`~confattr.config.Config` instance is created for each of these values (except if the key is contained in :paramref:`~confattr.config.DictConfig.ignore_keys`). See :meth:`~confattr.config.DictConfig.format_key`. 

326 :param type: How to parse, format and complete a value. Usually this is determined automatically based on :paramref:`~confattr.config.DictConfig.default_values`. But if you want more control you can implement your own class and pass it to this parameter. 

327 :param ignore_keys: All items which have one of these keys are *not* stored in a :class:`~confattr.config.Config` instance, i.e. cannot be set in the config file. 

328 :param unit: The unit of all items (only if type is None) 

329 :param allowed_values: The possible values these settings can have. Values read from a config file or an environment variable are checked against this. The :paramref:`~confattr.config.DictConfig.default_values` are *not* checked. (Only if type is None.) 

330 :param help: A help for all items 

331 :param sort: How to sort the items of this dictionary in the config file/documentation 

332 

333 :raises ValueError: if a key is not unique 

334 ''' 

335 self._values: 'dict[T_KEY, Config[T]]' = {} 

336 self._ignored_values: 'dict[T_KEY, T]' = {} 

337 self.allowed_values = allowed_values 

338 self.sort = sort 

339 

340 self.key_prefix = key_prefix 

341 self.key_changer = Config._key_changer[-1] if Config._key_changer else lambda key: key 

342 self.type = type 

343 self.unit = unit 

344 self.help = help 

345 self.ignore_keys = ignore_keys 

346 

347 for key, val in default_values.items(): 

348 self[key] = val 

349 

350 def format_key(self, key: T_KEY) -> str: 

351 ''' 

352 Generate a key by which the setting can be identified in the config file based on the dict key by which the value is accessed in the python code. 

353 

354 :return: :paramref:`~confattr.config.DictConfig.key_prefix` + dot + :paramref:`~confattr.config.DictConfig.format_key.key` 

355 ''' 

356 key_str = format_primitive_value(key) 

357 return '%s.%s' % (self.key_prefix, key_str) 

358 

359 def __setitem__(self: 'DictConfig[T_KEY, T]', key: T_KEY, val: T) -> None: 

360 if key in self.ignore_keys: 

361 self._ignored_values[key] = val 

362 return 

363 

364 c = self._values.get(key) 

365 if c is None: 

366 self._values[key] = self.new_config(self.format_key(key), val, unit=self.unit, help=self.help) 

367 else: 

368 c.value = val 

369 

370 def new_config(self: 'DictConfig[T_KEY, T]', key: str, default: T, *, unit: 'str|None', help: 'str|dict[T, str]|None') -> Config[T]: 

371 ''' 

372 Create a new :class:`~confattr.config.Config` instance to be used internally 

373 ''' 

374 return Config(key, default, type=self.type.copy() if self.type else None, unit=unit, help=help, parent=self, allowed_values=self.allowed_values) 

375 

376 def __getitem__(self, key: T_KEY) -> T: 

377 if key in self.ignore_keys: 

378 return self._ignored_values[key] 

379 else: 

380 return self._values[key].value 

381 

382 def get(self, key: T_KEY, default: 'T|None' = None) -> 'T|None': 

383 try: 

384 return self[key] 

385 except KeyError: 

386 return default 

387 

388 def __repr__(self) -> str: 

389 values = {key:val.value for key,val in self._values.items()} 

390 values.update({key:val for key,val in self._ignored_values.items()}) 

391 return '%s(%r, ignore_keys=%r, ...)' % (type(self).__name__, values, self.ignore_keys) 

392 

393 def __contains__(self, key: T_KEY) -> bool: 

394 if key in self.ignore_keys: 

395 return key in self._ignored_values 

396 else: 

397 return key in self._values 

398 

399 def __iter__(self) -> 'Iterator[T_KEY]': 

400 yield from self._values 

401 yield from self._ignored_values 

402 

403 def keys(self) -> 'Iterator[T_KEY]': 

404 yield from self._values.keys() 

405 yield from self._ignored_values.keys() 

406 

407 def values(self) -> 'Iterator[T]': 

408 for cfg in self._values.values(): 

409 yield cfg.value 

410 yield from self._ignored_values.values() 

411 

412 def items(self) -> 'Iterator[tuple[T_KEY, T]]': 

413 for key, cfg in self._values.items(): 

414 yield key, cfg.value 

415 yield from self._ignored_values.items() 

416 

417 

418 def iter_configs(self) -> 'Iterator[Config[T]]': 

419 ''' 

420 Iterate over the :class:`~confattr.config.Config` instances contained in this dict, 

421 sorted by the argument passed to :paramref:`~confattr.config.DictConfig.sort` in the constructor 

422 ''' 

423 if self.sort is self.Sort.NAME: 

424 yield from sorted(self._values.values(), key=lambda c: c.key) 

425 elif self.sort is self.Sort.NONE: 

426 yield from self._values.values() 

427 elif self.sort is self.Sort.ENUM_VALUE: 

428 #keys = typing.cast('Iterable[enum.Enum]', self._values.keys()) 

429 #keys = tuple(self._values) 

430 if is_mapping_with_enum_keys(self._values): 

431 for key in sorted(self._values.keys(), key=lambda c: c.value): 

432 yield self._values[key] 

433 else: 

434 raise TypeError("%r can only be used with enum keys" % self.sort) 

435 else: 

436 raise NotImplementedError("sort %r is not implemented" % self.sort) 

437 

438def is_mapping_with_enum_keys(m: 'Mapping[typing.Any, T]') -> 'typing.TypeGuard[Mapping[enum.Enum, T]]': 

439 return all(isinstance(key, enum.Enum) for key in m.keys()) 

440 

441 

442# ========== settings which can have different values for different groups ========== 

443 

444class MultiConfig(Config[T]): 

445 

446 ''' 

447 A setting which can have different values for different objects. 

448 

449 This class implements the `descriptor protocol <https://docs.python.org/3/reference/datamodel.html#implementing-descriptors>`__ to return one of the values in :attr:`~confattr.config.MultiConfig.values` depending on a ``config_id`` attribute of the owning object if an instance of this class is accessed as an instance attribute. 

450 If there is no value for the ``config_id`` in :attr:`~confattr.config.MultiConfig.values` :attr:`~confattr.config.MultiConfig.value` is returned instead. 

451 If the owning instance does not have a ``config_id`` attribute an :class:`AttributeError` is raised. 

452 

453 In the config file a group can be opened with ``[config-id]``. 

454 Then all following ``set`` commands set the value for the specified config id. 

455 ''' 

456 

457 #: A list of all config ids for which a value has been set in any instance of this class (regardless of via code or in a config file and regardless of whether the value has been deleted later on). This list is cleared by :meth:`~confattr.config.MultiConfig.reset`. 

458 config_ids: 'list[ConfigId]' = [] 

459 

460 #: Stores the values for specific objects. 

461 values: 'dict[ConfigId, T]' 

462 

463 #: Stores the default value which is used if no value for the object is defined in :attr:`~confattr.config.MultiConfig.values`. 

464 value: 'T' 

465 

466 #: The callable which has been passed to the constructor as :paramref:`~confattr.config.MultiConfig.check_config_id` 

467 check_config_id: 'Callable[[MultiConfig[T], ConfigId], None]|None' 

468 

469 @classmethod 

470 def reset(cls) -> None: 

471 ''' 

472 Clear :attr:`~confattr.config.MultiConfig.config_ids` and clear :attr:`~confattr.config.MultiConfig.values` for all instances in :attr:`Config.instances <confattr.config.Config.instances>` 

473 ''' 

474 cls.config_ids.clear() 

475 for cfg in Config.instances.values(): 

476 if isinstance(cfg, MultiConfig): 

477 cfg.values.clear() 

478 

479 def __init__(self, 

480 key: str, 

481 default: T, *, 

482 type: 'AbstractFormatter[T]|None' = None, 

483 unit: 'str|None' = None, 

484 allowed_values: 'Sequence[T]|dict[str, T]|None' = None, 

485 help: 'str|dict[T, str]|None' = None, 

486 parent: 'MultiDictConfig[typing.Any, T]|None' = None, 

487 check_config_id: 'Callable[[MultiConfig[T], ConfigId], None]|None' = None, 

488 ) -> None: 

489 ''' 

490 :param key: The name of this setting in the config file 

491 :param default: The default value of this setting 

492 :param help: A description of this setting 

493 :param type: How to parse, format and complete a value. Usually this is determined automatically based on :paramref:`~confattr.config.MultiConfig.default`. But if :paramref:`~confattr.config.MultiConfig.default` is an empty list the item type cannot be determined automatically so that this argument must be passed explicitly. This also gives the possibility to format a standard type differently e.g. as :class:`~confattr.formatters.Hex`. It is not permissible to reuse the same object for different settings, otherwise :meth:`AbstractFormatter.set_config_key() <confattr.formatters.AbstractFormatter.set_config_key>` will throw an exception. 

494 :param unit: The unit of an int or float value (only if type is None) 

495 :param allowed_values: The possible values this setting can have. Values read from a config file or an environment variable are checked against this. The :paramref:`~confattr.config.MultiConfig.default` value is *not* checked. (Only if type is None.) 

496 :param parent: Applies only if this is part of a :class:`~confattr.config.MultiDictConfig` 

497 :param check_config_id: Is called every time a value is set in the config file (except if the config id is :attr:`~confattr.config.Config.default_config_id`—that is always allowed). The callback should raise a :class:`~confattr.configfile.ParseException` if the config id is invalid. 

498 ''' 

499 super().__init__(key, default, type=type, unit=unit, help=help, parent=parent, allowed_values=allowed_values) 

500 self.values: 'dict[ConfigId, T]' = {} 

501 self.check_config_id = check_config_id 

502 

503 # I don't know why this code duplication is necessary, 

504 # I have declared the overloads in the parent class already. 

505 # But without copy-pasting this code mypy complains 

506 # "Signature of __get__ incompatible with supertype Config" 

507 @typing.overload 

508 def __get__(self, instance: None, owner: typing.Any = None) -> 'Self': 

509 pass 

510 

511 @typing.overload 

512 def __get__(self, instance: typing.Any, owner: typing.Any = None) -> T: 

513 pass 

514 

515 def __get__(self, instance: typing.Any, owner: typing.Any = None) -> 'T|Self': 

516 if instance is None: 

517 return self 

518 

519 return self.values.get(instance.config_id, self.value) 

520 

521 def __set__(self: 'MultiConfig[T]', instance: typing.Any, value: T) -> None: 

522 config_id = instance.config_id 

523 self.values[config_id] = value 

524 if config_id not in self.config_ids: 

525 self.config_ids.append(config_id) 

526 

527 def set_value(self: 'MultiConfig[T]', config_id: 'ConfigId|None', value: T) -> None: 

528 ''' 

529 Check :paramref:`~confattr.config.MultiConfig.set_value.config_id` by calling :meth:`~confattr.config.MultiConfig.check_config_id` and 

530 set the value for the object(s) identified by :paramref:`~confattr.config.MultiConfig.set_value.config_id`. 

531 

532 If you know that :paramref:`~confattr.config.MultiConfig.set_value.config_id` is valid you can also change the items of :attr:`~confattr.config.MultiConfig.values` directly. 

533 That is especially useful in test automation with :meth:`pytest.MonkeyPatch.setitem`. 

534 

535 If you want to set the default value you can also set :attr:`~confattr.config.MultiConfig.value` directly. 

536 

537 :param config_id: Identifies the object(s) for which :paramref:`~confattr.config.MultiConfig.set_value.value` is intended. :obj:`None` is equivalent to :attr:`~confattr.config.MultiConfig.default_config_id`. 

538 :param value: The value to be assigned for the object(s) identified by :paramref:`~confattr.config.MultiConfig.set_value.config_id`. 

539 ''' 

540 if config_id is None: 

541 config_id = self.default_config_id 

542 if self.check_config_id and config_id != self.default_config_id: 

543 self.check_config_id(self, config_id) 

544 if config_id == self.default_config_id: 

545 self.value = value 

546 else: 

547 self.values[config_id] = value 

548 if config_id not in self.config_ids: 

549 self.config_ids.append(config_id) 

550 

551 def get_value(self, config_id: 'ConfigId|None') -> T: 

552 ''' 

553 :return: The corresponding value from :attr:`~confattr.config.MultiConfig.values` if :paramref:`~confattr.config.MultiConfig.get_value.config_id` is contained or :attr:`~confattr.config.MultiConfig.value` otherwise 

554 ''' 

555 if config_id is None: 

556 config_id = self.default_config_id 

557 return self.values.get(config_id, self.value) 

558 

559 

560class MultiDictConfig(DictConfig[T_KEY, T]): 

561 

562 ''' 

563 A container for several settings which can have different values for different objects. 

564 

565 This is essentially a :class:`~confattr.config.DictConfig` using :class:`~confattr.config.MultiConfig` instead of normal :class:`~confattr.config.Config`. 

566 However, in order to return different values depending on the ``config_id`` of the owning instance, it implements the `descriptor protocol <https://docs.python.org/3/reference/datamodel.html#implementing-descriptors>`__ to return an :class:`~confattr.config.InstanceSpecificDictMultiConfig` if it is accessed as an instance attribute. 

567 ''' 

568 

569 def __init__(self, 

570 key_prefix: str, 

571 default_values: 'dict[T_KEY, T]', *, 

572 type: 'CopyableAbstractFormatter[T]|None' = None, 

573 ignore_keys: 'Container[T_KEY]' = set(), 

574 unit: 'str|None' = None, 

575 allowed_values: 'Sequence[T]|dict[str, T]|None' = None, 

576 help: 'str|None' = None, 

577 check_config_id: 'Callable[[MultiConfig[T], ConfigId], None]|None' = None, 

578 ) -> None: 

579 ''' 

580 :param key_prefix: A common prefix which is used by :meth:`~confattr.config.MultiDictConfig.format_key` to generate the :attr:`~confattr.config.Config.key` by which the setting is identified in the config file 

581 :param default_values: The content of this container. A :class:`~confattr.config.Config` instance is created for each of these values (except if the key is contained in :paramref:`~confattr.config.MultiDictConfig.ignore_keys`). See :meth:`~confattr.config.MultiDictConfig.format_key`. 

582 :param type: How to parse, format and complete a value. Usually this is determined automatically based on :paramref:`~confattr.config.MultiDictConfig.default_values`. But if you want more control you can implement your own class and pass it to this parameter. 

583 :param ignore_keys: All items which have one of these keys are *not* stored in a :class:`~confattr.config.Config` instance, i.e. cannot be set in the config file. 

584 :param unit: The unit of all items (only if type is None) 

585 :param allowed_values: The possible values these settings can have. Values read from a config file or an environment variable are checked against this. The :paramref:`~confattr.config.MultiDictConfig.default_values` are *not* checked. (Only if type is None.) 

586 :param help: A help for all items 

587 :param check_config_id: Is passed through to :class:`~confattr.config.MultiConfig` 

588 

589 :raises ValueError: if a key is not unique 

590 ''' 

591 self.check_config_id = check_config_id 

592 super().__init__( 

593 key_prefix = key_prefix, 

594 default_values = default_values, 

595 type = type, 

596 ignore_keys = ignore_keys, 

597 unit = unit, 

598 help = help, 

599 allowed_values = allowed_values, 

600 ) 

601 

602 @typing.overload 

603 def __get__(self, instance: None, owner: typing.Any = None) -> 'Self': 

604 pass 

605 

606 @typing.overload 

607 def __get__(self, instance: typing.Any, owner: typing.Any = None) -> 'InstanceSpecificDictMultiConfig[T_KEY, T]': 

608 pass 

609 

610 def __get__(self, instance: typing.Any, owner: typing.Any = None) -> 'InstanceSpecificDictMultiConfig[T_KEY, T]|Self': 

611 if instance is None: 

612 return self 

613 

614 return InstanceSpecificDictMultiConfig(self, instance.config_id) 

615 

616 def __set__(self: 'MultiDictConfig[T_KEY, T]', instance: typing.Any, value: 'InstanceSpecificDictMultiConfig[T_KEY, T]') -> typing.NoReturn: 

617 raise NotImplementedError() 

618 

619 def new_config(self: 'MultiDictConfig[T_KEY, T]', key: str, default: T, *, unit: 'str|None', help: 'str|dict[T, str]|None') -> MultiConfig[T]: 

620 return MultiConfig(key, default, type=self.type.copy() if self.type else None, unit=unit, help=help, parent=self, allowed_values=self.allowed_values, check_config_id=self.check_config_id) 

621 

622class InstanceSpecificDictMultiConfig(typing.Generic[T_KEY, T]): 

623 

624 ''' 

625 An intermediate instance which is returned when accsessing 

626 a :class:`~confattr.config.MultiDictConfig` as an instance attribute. 

627 Can be indexed like a normal :class:`dict`. 

628 ''' 

629 

630 def __init__(self, mdc: 'MultiDictConfig[T_KEY, T]', config_id: ConfigId) -> None: 

631 self.mdc = mdc 

632 self.config_id = config_id 

633 

634 def __setitem__(self: 'InstanceSpecificDictMultiConfig[T_KEY, T]', key: T_KEY, val: T) -> None: 

635 if key in self.mdc.ignore_keys: 

636 raise TypeError('cannot set value of ignored key %r' % key) 

637 

638 c = self.mdc._values.get(key) 

639 if c is None: 

640 self.mdc._values[key] = MultiConfig(self.mdc.format_key(key), val, help=self.mdc.help) 

641 else: 

642 c.__set__(self, val) 

643 

644 def __getitem__(self, key: T_KEY) -> T: 

645 if key in self.mdc.ignore_keys: 

646 return self.mdc._ignored_values[key] 

647 else: 

648 return self.mdc._values[key].__get__(self)