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

1#!./runmodule.sh

2

3 import builtins

4 import enum

5 import typing

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

7

8 if typing.TYPE_CHECKING:

9 from typing_extensions import Self

10

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

12

13

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

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

16

17 T_KEY = typing.TypeVar('T_KEY')

18 T = typing.TypeVar('T')

19

20

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

22

23 '''

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

25

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

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

28 '''

29

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

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

32

33 default_config_id = ConfigId('general')

34

35 #: The value of this setting.

36 value: 'T'

37

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

39 type: 'AbstractFormatter[T]'

40

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

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

43

44

45 def __init__(self,

46 key: str,

47 default: T, *,

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

49 unit: 'str|None' = None,

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

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

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

53 ):

54 '''

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

56 :param default: The default value of this setting

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

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

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

60 :param help: A description of this setting

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

62

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

64 * :class:`str`

65 * :class:`int`

66 * :class:`float`

67 * :class:`bool`

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

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

70 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>`. \

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

72 * 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.)

73

74 :raises ValueError: if key is not unique

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

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

77 '''

78 if type is None:

79 if isinstance(default, list):

80 if not default:

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

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

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

84 elif isinstance(default, set):

85 if not default:

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

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

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

89 elif isinstance(default, dict):

90 if not default:

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

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

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

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

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

96 else:

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

98 else:

99 if unit is not None:

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

101 if allowed_values is not None:

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

103

104 type.set_config_key(key)

105

106 self._key = key

107 self.value = default

108 self.type = type

109 self.help = help

110 self.parent = parent

111

112 cls = builtins.type(self)

113 if key in cls.instances:

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

115 cls.instances[key] = self

116

117 @property

118 def key(self) -> str:

119 '''The name of this setting which is used in the config file. This must be unique.'''

120 return self._key

121

122 @key.setter

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

124 if key in self.instances:

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

126 del self.instances[self._key]

127 self._key = key

128 self.type.config_key = key

129 self.instances[key] = self

130

131

132 @typing.overload

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

134 pass

135

136 @typing.overload

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

138 pass

139

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

141 if instance is None:

142 return self

143

144 return self.value

145

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

147 self.value = value

148

149 def __repr__(self) -> str:

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

151

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

153 '''

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

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

156 '''

157 if config_id is None:

158 config_id = self.default_config_id

159 if config_id != self.default_config_id:

160 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}')

161 self.value = value

162

163 def wants_to_be_exported(self) -> bool:

164 return True

165

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

167 '''

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

169

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

171 '''

172 return self.value

173

174

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

176

177 '''

178 A container for several settings which belong together.

179 It can be indexed like a normal :class:`dict` but internally the items are stored in :class:`~confattr.config.Config` instances.

180

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

182 '''

183

184 def __init__(self,

185 key_prefix: str,

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

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

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

189 unit: 'str|None' = None,

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

191 help: 'str|None' = None,

192 ) -> None:

193 '''

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

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

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

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

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

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

200 :param help: A help for all items

201

202 :raises ValueError: if a key is not unique

203 '''

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

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

206 self.allowed_values = allowed_values

207

208 self.key_prefix = key_prefix

209 self.type = type

210 self.unit = unit

211 self.help = help

212 self.ignore_keys = ignore_keys

213

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

215 self[key] = val

216

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

218 '''

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

220

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

222 '''

223 key_str = format_primitive_value(key)

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

225

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

227 if key in self.ignore_keys:

228 self._ignored_values[key] = val

229 return

230

231 c = self._values.get(key)

232 if c is None:

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

234 else:

235 c.value = val

236

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

238 '''

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

240 '''

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

242

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

244 if key in self.ignore_keys:

245 return self._ignored_values[key]

246 else:

247 return self._values[key].value

248

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

250 try:

251 return self[key]

252 except KeyError:

253 return default

254

255 def __repr__(self) -> str:

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

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

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

259

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

261 if key in self.ignore_keys:

262 return key in self._ignored_values

263 else:

264 return key in self._values

265

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

267 yield from self._values

268 yield from self._ignored_values

269

270 def iter_keys(self) -> 'Iterator[str]':

271 '''

272 Iterate over the keys by which the settings can be identified in the config file

273 '''

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

275 yield cfg.key

276

277

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

279

280 class MultiConfig(Config[T]):

281

282 '''

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

284

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

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

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

288

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

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

291 '''

292

293 #: 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`.

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

295

296 #: Stores the values for specific objects.

297 values: 'dict[ConfigId, T]'

298

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

300 value: 'T'

301

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

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

304

305 @classmethod

306 def reset(cls) -> None:

307 '''

308 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>`

309 '''

310 cls.config_ids.clear()

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

312 if isinstance(cfg, MultiConfig):

313 cfg.values.clear()

314

315 def __init__(self,

316 key: str,

317 default: T, *,

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

319 unit: 'str|None' = None,

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

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

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

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

324 ) -> None:

325 '''

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

327 :param default: The default value of this setting

328 :param help: A description of this setting

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

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

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

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

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

334 '''

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

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

337 self.check_config_id = check_config_id

338

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

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

341 # But without copy-pasting this code mypy complains

342 # "Signature of __get__ incompatible with supertype Config"

343 @typing.overload

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

345 pass

346

347 @typing.overload

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

349 pass

350

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

352 if instance is None:

353 return self

354

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

356

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

358 config_id = instance.config_id

359 self.values[config_id] = value

360 if config_id not in self.config_ids:

361 self.config_ids.append(config_id)

362

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

364 '''

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

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

367

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

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

370

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

372

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

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

375 '''

376 if config_id is None:

377 config_id = self.default_config_id

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

379 self.check_config_id(self, config_id)

380 if config_id == self.default_config_id:

381 self.value = value

382 else:

383 self.values[config_id] = value

384 if config_id not in self.config_ids:

385 self.config_ids.append(config_id)

386

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

388 '''

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

390 '''

391 if config_id is None:

392 config_id = self.default_config_id

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

394

395

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

397

398 '''

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

400

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

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

403 '''

404

405 def __init__(self,

406 key_prefix: str,

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

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

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

410 unit: 'str|None' = None,

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

412 help: 'str|None' = None,

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

414 ) -> None:

415 '''

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

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

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

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

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

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

422 :param help: A help for all items

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

424

425 :raises ValueError: if a key is not unique

426 '''

427 self.check_config_id = check_config_id

428 super().__init__(

429 key_prefix = key_prefix,

430 default_values = default_values,

431 type = type,

432 ignore_keys = ignore_keys,

433 unit = unit,

434 help = help,

435 allowed_values = allowed_values,

436 )

437

438 @typing.overload

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

440 pass

441

442 @typing.overload

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

444 pass

445

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

447 if instance is None:

448 return self

449

450 return InstanceSpecificDictMultiConfig(self, instance.config_id)

451

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

453 raise NotImplementedError()

454

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

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

457

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

459

460 '''

461 An intermediate instance which is returned when accsessing

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

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

464 '''

465

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

467 self.mdc = mdc

468 self.config_id = config_id

469

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

471 if key in self.mdc.ignore_keys:

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

473

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

475 if c is None:

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

477 else:

478 c.__set__(self, val)

479

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

481 if key in self.mdc.ignore_keys:

482 return self.mdc._ignored_values[key]

483 else:

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