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

1#!/usr/bin/env python3

2

3 import re

4 import copy

5 import abc

6 import enum

7 import typing

8 import builtins

9 from collections.abc import Iterable, Iterator, Sequence, Mapping, Callable

10

11 if typing.TYPE_CHECKING:

12 from .configfile import ConfigFile

13 from typing_extensions import Self

14

15 try:

16 Collection = typing.Collection

17 except: # pragma: no cover

18 from collections.abc import Collection

19

20

21 TYPES_REQUIRING_UNIT = {int, float}

22

23 VALUE_TRUE = 'true'

24 VALUE_FALSE = 'false'

25

26 def format_primitive_value(value: object) -> str:

27 if isinstance(value, enum.Enum):

28 return value.name.lower().replace('_', '-')

29 if isinstance(value, bool):

30 return VALUE_TRUE if value else VALUE_FALSE

31 return str(value)

32

33

34# mypy rightfully does not allow AbstractFormatter to be declared as covariant with respect to T because

35# def format_value(self, t: AbstractFormatter[object], val: object):

36# return t.format_value(self, val)

37# ...

38# config_file.format_value(Hex(), "boom")

39# would typecheck ok but crash

40 T = typing.TypeVar('T')

41

42 class AbstractFormatter(typing.Generic[T]):

43

44 '''

45 An abstract base class for classes which define how to parse, format and complete a value.

46 Instances of (subclasses of this class) can be passed to the :paramref:`~confattr.config.Config.type` attribute of settings.

47 '''

48

49 config_key: 'str|None' = None

50

51 @abc.abstractmethod

52 def format_value(self, config_file: 'ConfigFile', value: 'T') -> str:

53 raise NotImplementedError()

54

55 @abc.abstractmethod

56 def expand_value(self, config_file: 'ConfigFile', value: 'T', format_spec: str) -> str:

57 '''

58 :param config_file: has e.g. the :attr:`~confattr.configfile.ConfigFile.ITEM_SEP` attribute

59 :param value: The value to be formatted

60 :param format_spec: A format specifier

61 :return: :paramref:`~confattr.formatters.AbstractFormatter.expand_value.value` formatted according to :paramref:`~confattr.formatters.AbstractFormatter.expand_value.format_spec`

62 :raises ValueError, LookupError: If :paramref:`~confattr.formatters.AbstractFormatter.expand_value.format_spec` is invalid

63 '''

64 raise NotImplementedError()

65

66 @abc.abstractmethod

67 def parse_value(self, config_file: 'ConfigFile', value: str) -> 'T':

68 raise NotImplementedError()

69

70 @abc.abstractmethod

71 def get_description(self, config_file: 'ConfigFile') -> str:

72 raise NotImplementedError()

73

74 @abc.abstractmethod

75 def get_completions(self, config_file: 'ConfigFile', start_of_line: str, start: str, end_of_line: str) -> 'tuple[str, list[str], str]':

76 raise NotImplementedError()

77

78 @abc.abstractmethod

79 def get_primitives(self) -> 'Sequence[Primitive[typing.Any]]':

80 '''

81 If self is a Primitive data type, return self.

82 If self is a Collection, return self.item_type.

83 '''

84 raise NotImplementedError()

85

86 def set_config_key(self, config_key: str) -> None:

87 '''

88 In order to generate a useful error message if parsing a value fails the key of the setting is required.

89 This method is called by the constructor of :class:`~confattr.config.Config`.

90 This method must not be called more than once.

91

92 :raises TypeError: If :attr:`~confattr.formatters.AbstractFormatter.config_key` has already been set.

93 '''

94 if self.config_key:

95 raise TypeError(f"config_key has already been set to {self.config_key!r}, not setting to {config_key!r}")

96 self.config_key = config_key

97

98

99 class CopyableAbstractFormatter(AbstractFormatter[T]):

100

101 @abc.abstractmethod

102 def copy(self) -> 'Self':

103 raise NotImplementedError()

104

105

106 class Primitive(CopyableAbstractFormatter[T]):

107

108 PATTERN_ONE_OF = "one of {}"

109 PATTERN_ALLOWED_VALUES_UNIT = "{allowed_values} (unit: {unit})"

110 PATTERN_TYPE_UNIT = "{type} in {unit}"

111

112 #: Help for data types. This is used by :meth:`~confattr.formatters.Primitive.get_help`.

113 help_dict: 'dict[type[typing.Any]|Callable[..., typing.Any], str]' = {

114 str : 'A text. If it contains spaces it must be wrapped in single or double quotes.',

115 int : '''\

116 An integer number in python 3 syntax, as decimal (e.g. 42), hexadecimal (e.g. 0x2a), octal (e.g. 0o52) or binary (e.g. 0b101010).

117 Leading zeroes are not permitted to avoid confusion with python 2's syntax for octal numbers.

118 It is permissible to group digits with underscores for better readability, e.g. 1_000_000.''',

119 #bool,

120 float : 'A floating point number in python syntax, e.g. 23, 1.414, -1e3, 3.14_15_93.',

121 }

122

123

124 #: If this is set it is used in :meth:`~confattr.formatters.Primitive.get_description` and the list of possible values is moved to the output of :meth:`~confattr.formatters.Primitive.get_help`.

125 type_name: 'str|None'

126

127 #: The unit of a number

128 unit: 'str|None'

129

130 #: :class:`str`, :class:`int`, :class:`float`, :class:`bool`, a subclass of :class:`enum.Enum` or any class that follows the pattern of :class:`confattr.types.AbstractType`

131 type: 'type[T]|Callable[..., T]'

132

133 #: If this is set and a value read from a config file is not contained it is considered invalid. If this is a mapping the keys are the string representations used in the config file.

134 allowed_values: 'Collection[T]|dict[str, T]|None'

135

136 def __init__(self, type: 'builtins.type[T]|Callable[..., T]', *, allowed_values: 'Collection[T]|dict[str, T]|None' = None, unit: 'str|None' = None, type_name: 'str|None' = None) -> None:

137 '''

138 :param type: :class:`str`, :class:`int`, :class:`float`, :class:`bool`, a subclass of :class:`enum.Enum` or any class which looks like :class:`~confattr.types.AbstractType`

139 :param unit: The unit of an int or float value

140 :param allowed_values: The possible values this setting can have. Values read from a config file or an environment variable are checked against this.

141 :param type_name: A name for this type which is used in the config file.

142 '''

143 if type in TYPES_REQUIRING_UNIT and unit is None and not isinstance(allowed_values, dict):

144 raise TypeError(f"missing argument unit for {self.config_key}, pass an empty string if the number really has no unit")

145

146 self.type = type

147 self.type_name = type_name

148 self.allowed_values = allowed_values

149 self.unit = unit

150

151 def copy(self) -> 'Self':

152 out = copy.copy(self)

153 out.config_key = None

154 return out

155

156 def format_value(self, config_file: 'ConfigFile', value: 'T') -> str:

157 if isinstance(self.allowed_values, dict):

158 for key, val in self.allowed_values.items():

159 if val == value:

160 return key

161 raise ValueError('%r is not an allowed value, should be one of %s' % (value, ', '.join(repr(v) for v in self.allowed_values.values())))

162

163 if isinstance(value, str):

164 return value.replace('\n', r'\n')

165

166 return format_primitive_value(value)

167

168 def expand_value(self, config_file: 'ConfigFile', value: 'T', format_spec: str) -> str:

169 '''

170 This method simply calls the builtin :func:`format`.

171 '''

172 return format(value, format_spec)

173

174 def parse_value(self, config_file: 'ConfigFile', value: str) -> 'T':

175 if isinstance(self.allowed_values, dict):

176 try:

177 return self.allowed_values[value]

178 except KeyError:

179 raise ValueError(f'invalid value for {self.config_key}: {value!r} (should be {self.get_description(config_file)})')

180 elif self.type is str:

181 value = value.replace(r'\n', '\n')

182 out = typing.cast(T, value)

183 elif self.type is int:

184 out = typing.cast(T, int(value, base=0))

185 elif self.type is float:

186 out = typing.cast(T, float(value))

187 elif self.type is bool:

188 if value == VALUE_TRUE:

189 out = typing.cast(T, True)

190 elif value == VALUE_FALSE:

191 out = typing.cast(T, False)

192 else:

193 raise ValueError(f'invalid value for {self.config_key}: {value!r} (should be {self.get_description(config_file)})')

194 elif isinstance(self.type, type) and issubclass(self.type, enum.Enum):

195 for i in self.type:

196 enum_item = typing.cast(T, i)

197 if self.format_value(config_file, enum_item) == value:

198 out = enum_item

199 break

200 else:

201 raise ValueError(f'invalid value for {self.config_key}: {value!r} (should be {self.get_description(config_file)})')

202 else:

203 try:

204 out = self.type(value) # type: ignore [call-arg]

205 except Exception as e:

206 raise ValueError(f'invalid value for {self.config_key}: {value!r} ({e})')

207

208 if self.allowed_values is not None and out not in self.allowed_values:

209 raise ValueError(f'invalid value for {self.config_key}: {value!r} (should be {self.get_description(config_file)})')

210 return out

211

212

213 def get_description(self, config_file: 'ConfigFile', *, plural: bool = False, article: bool = True) -> str:

214 '''

215 :param config_file: May contain some additional information how to format the allowed values.

216 :param plural: Whether the return value should be a plural form.

217 :param article: Whether the return value is supposed to be formatted with :meth:`~confattr.formatters.Primitive.format_indefinite_singular_article` (if :meth:`~confattr.formatters.Primitive.get_type_name` is used) or :attr:`~confattr.formatters.Primitive.PATTERN_ONE_OF` (if :meth:`~confattr.formatters.Primitive.get_allowed_values` returns an empty sequence). This is assumed to be false if :paramref:`~confattr.formatters.Primitive.get_description.plural` is true.

218 :return: A short description which is displayed in the help/comment for each setting explaining what kind of value is expected.

219 In the easiest case this is just a list of allowed value, e.g. "one of true, false".

220 If :attr:`~confattr.formatters.Primitive.type_name` has been passed to the constructor this is used instead and the list of possible values is moved to the output of :meth:`~confattr.formatters.Primitive.get_help`.

221 If a unit is specified it is included, e.g. "an int in km/h".

222

223 You can customize the return value of this method by overriding :meth:`~confattr.formatters.Primitive.get_type_name`, :meth:`~confattr.formatters.Primitive.join` or :meth:`~confattr.formatters.Primitive.format_indefinite_singular_article`

224 or by changing the value of :attr:`~confattr.formatters.Primitive.PATTERN_ONE_OF`, :attr:`~confattr.formatters.Primitive.PATTERN_ALLOWED_VALUES_UNIT` or :attr:`~confattr.formatters.Primitive.PATTERN_TYPE_UNIT`.

225 '''

226 if plural:

227 article = False

228

229 if not self.type_name:

230 out = self.format_allowed_values(config_file, article=article)

231 if out:

232 return out

233

234 out = self.get_type_name()

235 if self.unit:

236 out = self.PATTERN_TYPE_UNIT.format(type=out, unit=self.unit)

237 if article:

238 out = self.format_indefinite_singular_article(out)

239 return out

240

241 def format_allowed_values(self, config_file: 'ConfigFile', *, article: bool = True) -> 'str|None':

242 allowed_values = self.get_allowed_values()

243 if not allowed_values:

244 return None

245

246 out = self.join(self.format_value(config_file, v) for v in allowed_values)

247 if article:

248 out = self.PATTERN_ONE_OF.format(out)

249 if self.unit:

250 out = self.PATTERN_ALLOWED_VALUES_UNIT.format(allowed_values=out, unit=self.unit)

251 return out

252

253 def get_type_name(self) -> str:

254 '''

255 Return the name of this type (without :attr:`~confattr.formatters.Primitive.unit` or :attr:`~confattr.formatters.Primitive.allowed_values`).

256 This can be used in :meth:`~confattr.formatters.Primitive.get_description` if the type can have more than just a couple of values.

257 If that is the case a help should be provided by :meth:`~confattr.formatters.Primitive.get_help`.

258

259 :return: :paramref:`~confattr.formatters.Primitive.type_name` if it has been passed to the constructor, the value of an attribute of :attr:`~confattr.formatters.Primitive.type` called ``type_name`` if existing or the lower case name of the class stored in :attr:`~confattr.formatters.Primitive.type` otherwise

260 '''

261 if self.type_name:

262 return self.type_name

263 return getattr(self.type, 'type_name', self.type.__name__.lower())

264

265 def join(self, names: 'Iterable[str]') -> str:

266 '''

267 Join several values which have already been formatted with :meth:`~confattr.formatters.Primitive.format_value`.

268 '''

269 return ', '.join(names)

270

271 def format_indefinite_singular_article(self, type_name: str) -> str:

272 '''

273 Getting the article right is not so easy, so a user can specify the correct article with a str attribute called ``type_article``.

274 Alternatively this method can be overridden.

275 This also gives the possibility to omit the article.

276 https://en.wiktionary.org/wiki/Appendix:English_articles#Indefinite_singular_articles

277

278 This is used in :meth:`~confattr.formatters.Primitive.get_description`.

279 '''

280 if hasattr(self.type, 'type_article'):

281 article = getattr(self.type, 'type_article')

282 if not article:

283 return type_name

284 assert isinstance(article, str)

285 return article + ' ' + type_name

286 if type_name[0].lower() in 'aeio':

287 return 'an ' + type_name

288 return 'a ' + type_name

289

290

291 def get_help(self, config_file: 'ConfigFile') -> 'str|None':

292 '''

293 The help for the generic data type, independent of the unit.

294 This is displayed once at the top of the help or the config file (if one or more settings use this type).

295

296 For example the help for an int might be:

297

298 An integer number in python 3 syntax, as decimal (e.g. 42), hexadecimal (e.g. 0x2a), octal (e.g. 0o52) or binary (e.g. 0b101010).

299 Leading zeroes are not permitted to avoid confusion with python 2's syntax for octal numbers.

300 It is permissible to group digits with underscores for better readability, e.g. 1_000_000.

301

302 Return None if (and only if) :meth:`~confattr.formatters.Primitive.get_description` returns a simple list of all possible values and not :meth:`~confattr.formatters.Primitive.get_type_name`.

303

304 :return: The corresponding value in :attr:`~confattr.formatters.Primitive.help_dict`, the value of an attribute called ``help`` on the :attr:`~confattr.formatters.Primitive.type` or None if the return value of :meth:`~confattr.formatters.Primitive.get_allowed_values` is empty.

305 :raises TypeError: If the ``help`` attribute is not a str. If you have no influence over this attribute you can avoid checking it by adding a corresponding value to :attr:`~confattr.formatters.Primitive.help_dict`.

306 :raises NotImplementedError: If there is no help or list of allowed values. If this is raised add a ``help`` attribute to the class or a value for it in :attr:`~confattr.formatters.Primitive.help_dict`.

307 '''

308

309 if self.type_name:

310 allowed_values = self.format_allowed_values(config_file)

311 if not allowed_values:

312 raise NotImplementedError("used 'type_name' without 'allowed_values', please override 'get_help'")

313 return allowed_values[:1].upper() + allowed_values[1:]

314

315 if self.type in self.help_dict:

316 return self.help_dict[self.type]

317 elif hasattr(self.type, 'help'):

318 out = getattr(self.type, 'help')

319 if not isinstance(out, str):

320 raise TypeError(f"help attribute of {self.type.__name__!r} has invalid type {type(out).__name__!r}, if you cannot change that attribute please add an entry in Primitive.help_dict")

321 return out

322 elif self.get_allowed_values():

323 return None

324 else:

325 raise NotImplementedError('No help for type %s' % self.get_type_name())

326

327

328 def get_completions(self, config_file: 'ConfigFile', start_of_line: str, start: str, end_of_line: str) -> 'tuple[str, list[str], str]':

329 completions = [config_file.quote(config_file.format_any_value(self, val)) for val in self.get_allowed_values()]

330 completions = [v for v in completions if v.startswith(start)]

331 return start_of_line, completions, end_of_line

332

333 def get_allowed_values(self) -> 'Collection[T]':

334 if isinstance(self.allowed_values, dict):

335 return self.allowed_values.values()

336 if self.allowed_values:

337 return self.allowed_values

338 if self.type is bool:

339 return (typing.cast(T, True), typing.cast(T, False))

340 if isinstance(self.type, type) and issubclass(self.type, enum.Enum):

341 return self.type

342 if hasattr(self.type, 'get_instances'):

343 return self.type.get_instances() # type: ignore [union-attr,no-any-return] # mypy does not understand that I have just checked the existence of get_instances

344 return ()

345

346 def get_primitives(self) -> 'tuple[Self]':

347 return (self,)

348

349 class Hex(Primitive[int]):

350

351 def __init__(self, *, allowed_values: 'Collection[int]|None' = None) -> None:

352 super().__init__(int, allowed_values=allowed_values, unit='')

353

354 def format_value(self, config_file: 'ConfigFile', value: int) -> str:

355 return '%X' % value

356

357 def parse_value(self, config_file: 'ConfigFile', value: str) -> int:

358 return int(value, base=16)

359

360 def get_description(self, config_file: 'ConfigFile', *, plural: bool = False, article: bool = True) -> str:

361 out = 'hexadecimal number'

362 if plural:

363 out += 's'

364 elif article:

365 out = 'a ' + out

366 return out

367

368 def get_help(self, config_file: 'ConfigFile') -> None:

369 return None

370

371

372 class AbstractCollection(AbstractFormatter[Collection[T]]):

373

374 def __init__(self, item_type: 'Primitive[T]') -> None:

375 self.item_type = item_type

376

377 def split_values(self, config_file: 'ConfigFile', values: str) -> 'Iterable[str]':

378 return values.split(config_file.ITEM_SEP)

379

380 def get_completions(self, config_file: 'ConfigFile', start_of_line: str, start: str, end_of_line: str) -> 'tuple[str, list[str], str]':

381 if config_file.ITEM_SEP in start:

382 first, start = start.rsplit(config_file.ITEM_SEP, 1)

383 start_of_line += first + config_file.ITEM_SEP

384 return self.item_type.get_completions(config_file, start_of_line, start, end_of_line)

385

386 def get_primitives(self) -> 'tuple[Primitive[T]]':

387 return (self.item_type,)

388

389 def set_config_key(self, config_key: str) -> None:

390 super().set_config_key(config_key)

391 self.item_type.set_config_key(config_key)

392

393

394 # ------- expand ------

395

396 def expand_value(self, config_file: 'ConfigFile', values: 'Collection[T]', format_spec: str) -> str:

397 '''

398 :paramref:`~confattr.formatters.AbstractCollection.expand_value.format_spec` supports the following features:

399

400 - Filter out some values, e.g. ``-foo,bar`` expands to all items except for ``foo`` and ``bar``, it is no error if ``foo`` or ``bar`` are not contained

401 - Get the length, ``len`` expands to the number of items

402 - Get extreme values, ``min`` expands to the smallest item and ``max`` expands to the biggest item, raises :class:`TypeError` if the items are not comparable

403

404 To any of the above you can append another format_spec after a colon to specify how to format the items/the length.

405 '''

406 m = re.match(r'(-(?P<exclude>[^[:]*)|(?P<func>[^[:]*))(:(?P<format_spec>.*))?$', format_spec)

407 if m is None:

408 raise ValueError('Invalid format_spec for collection: %r' % format_spec)

409

410 format_spec = m.group('format_spec') or ''

411 func = m.group('func')

412 if func == 'len':

413 return self.expand_length(config_file, values, format_spec)

414 elif func:

415 return self.expand_min_max(config_file, values, func, format_spec)

416

417 exclude = m.group('exclude')

418 if exclude:

419 return self.expand_exclude_items(config_file, values, exclude, format_spec)

420

421 return self.expand_parsed_items(config_file, values, format_spec)

422

423 def expand_length(self, config_file: 'ConfigFile', values: 'Collection[T]', int_format_spec: str) -> str:

424 return format(len(values), int_format_spec)

425

426 def expand_min_max(self, config_file: 'ConfigFile', values: 'Collection[T]', func: str, item_format_spec: str) -> str:

427 if func == 'min':

428 v = min(values) # type: ignore [type-var] # The TypeError is caught in ConfigFile.expand_config_match

429 elif func == 'max':

430 v = max(values) # type: ignore [type-var] # The TypeError is caught in ConfigFile.expand_config_match

431 else:

432 raise ValueError(f'Invalid format_spec for collection: {func!r}')

433

434 return self.expand_parsed_items(config_file, [v], item_format_spec)

435

436 def expand_exclude_items(self, config_file: 'ConfigFile', values: 'Collection[T]', items_to_be_excluded: str, item_format_spec: str) -> str:

437 exclude = {self.item_type.parse_value(config_file, item) for item in items_to_be_excluded.split(',')}

438 out = [v for v in values if v not in exclude]

439 return self.expand_parsed_items(config_file, out, item_format_spec)

440

441 def expand_parsed_items(self, config_file: 'ConfigFile', values: 'Collection[T]', item_format_spec: str) -> str:

442 if not item_format_spec:

443 return self.format_value(config_file, values)

444 return config_file.ITEM_SEP.join(format(v, item_format_spec) for v in values)

445

446 class List(AbstractCollection[T]):

447

448 def get_description(self, config_file: 'ConfigFile') -> str:

449 return 'a comma separated list of ' + self.item_type.get_description(config_file, plural=True)

450

451 def format_value(self, config_file: 'ConfigFile', values: 'Collection[T]') -> str:

452 return config_file.ITEM_SEP.join(config_file.format_any_value(self.item_type, i) for i in values)

453

454 def expand_value(self, config_file: 'ConfigFile', values: 'Sequence[T]', format_spec: str) -> str: # type: ignore [override] # supertype defines the argument type as "Collection[T]", yes because type vars depending on other type vars is not supported yet https://github.com/python/typing/issues/548

455 '''

456 :paramref:`~confattr.formatters.List.expand_value.format_spec` supports all features inherited from :meth:`AbstractCollection.expand_value() <confattr.formatters.AbstractCollection.expand_value>` as well as the following:

457

458 - Access a single item, e.g. ``[0]`` expands to the first item, ``[-1]`` expands to the last item [1]

459 - Access several items, e.g. ``[0,2,5]`` expands to the items at index 0, 2 and 5, if the list is not that long an :class:`IndexError` is raised

460 - Access a slice of items, e.g. ``[:3]`` expands to the first three items or to as many items as the list is long if the list is not that long [1]

461 - Access a slice of items with a step, e.g. ``[::-1]`` expands to all items in reverse order [1]

462

463 To any of the above you can append another format_spec after a colon to specify how to format the items.

464

465 [1] For more information see the `common slicing operations of sequences <https://docs.python.org/3/library/stdtypes.html#common-sequence-operations>`__.

466 '''

467 m = re.match(r'(\[(?P<indices>[^]]+)\])(:(?P<format_spec>.*))?$', format_spec)

468 if m is None:

469 return super().expand_value(config_file, values, format_spec)

470

471 format_spec = m.group('format_spec') or ''

472 indices = m.group('indices')

473 assert isinstance(indices, str)

474 return self.expand_items(config_file, values, indices, format_spec)

475

476 def expand_items(self, config_file: 'ConfigFile', values: 'Sequence[T]', indices: str, item_format_spec: str) -> str:

477 out = [v for sl in self.parse_slices(indices) for v in values[sl]]

478 return self.expand_parsed_items(config_file, out, item_format_spec)

479

480 def parse_slices(self, indices: str) -> 'Iterator[slice]':

481 for s in indices.split(','):

482 yield self.parse_slice(s)

483

484 def parse_slice(self, s: str) -> 'slice':

485 sl = [int(i) if i else None for i in s.split(':')]

486 if len(sl) == 1 and isinstance(sl[0], int):

487 i = sl[0]

488 return slice(i, i+1)

489 return slice(*sl)

490

491 def parse_value(self, config_file: 'ConfigFile', values: str) -> 'list[T]':

492 return [self.item_type.parse_value(config_file, i) for i in self.split_values(config_file, values)]

493

494 class Set(AbstractCollection[T]):

495

496 def get_description(self, config_file: 'ConfigFile') -> str:

497 return 'a comma separated set of ' + self.item_type.get_description(config_file, plural=True)

498

499 def format_value(self, config_file: 'ConfigFile', values: 'Collection[T]') -> str:

500 try:

501 sorted_values = sorted(values) # type: ignore [type-var] # values may be not comparable but that's what the try/except is there for

502 except TypeError:

503 return config_file.ITEM_SEP.join(sorted(config_file.format_any_value(self.item_type, i) for i in values))

504

505 return config_file.ITEM_SEP.join(config_file.format_any_value(self.item_type, i) for i in sorted_values)

506

507 def parse_value(self, config_file: 'ConfigFile', values: str) -> 'set[T]':

508 return {self.item_type.parse_value(config_file, i) for i in self.split_values(config_file, values)}

509

510

511 T_key = typing.TypeVar('T_key')

512 T_val = typing.TypeVar('T_val')

513 class Dict(AbstractFormatter['dict[T_key, T_val]']):

514

515 def __init__(self, key_type: 'Primitive[T_key]', value_type: 'Primitive[T_val]') -> None:

516 self.key_type = key_type

517 self.value_type = value_type

518

519 def get_description(self, config_file: 'ConfigFile') -> str:

520 return 'a dict of %s:%s' % (self.key_type.get_description(config_file, article=False), self.value_type.get_description(config_file, article=False))

521

522 def format_value(self, config_file: 'ConfigFile', values: 'Mapping[T_key, T_val]') -> str:

523 return config_file.ITEM_SEP.join(config_file.format_any_value(self.key_type, key) + config_file.KEY_SEP + config_file.format_any_value(self.value_type, val) for key, val in values.items())

524

525 def parse_value(self, config_file: 'ConfigFile', values: str) -> 'dict[T_key, T_val]':

526 return dict(self.parse_item(config_file, i) for i in self.split_values(config_file, values))

527

528 def split_values(self, config_file: 'ConfigFile', values: str) -> 'Iterable[str]':

529 return values.split(config_file.ITEM_SEP)

530

531 def parse_item(self, config_file: 'ConfigFile', item: str) -> 'tuple[T_key, T_val]':

532 key_name, val_name = item.split(config_file.KEY_SEP, 1)

533 key = self.key_type.parse_value(config_file, key_name)

534 val = self.value_type.parse_value(config_file, val_name)

535 return key, val

536

537 def get_primitives(self) -> 'tuple[Primitive[T_key], Primitive[T_val]]':

538 return (self.key_type, self.value_type)

539

540 def get_completions(self, config_file: 'ConfigFile', start_of_line: str, start: str, end_of_line: str) -> 'tuple[str, list[str], str]':

541 if config_file.ITEM_SEP in start:

542 first, start = start.rsplit(config_file.ITEM_SEP, 1)

543 start_of_line += first + config_file.ITEM_SEP

544 if config_file.KEY_SEP in start:

545 first, start = start.rsplit(config_file.KEY_SEP, 1)

546 start_of_line += first + config_file.KEY_SEP

547 return self.value_type.get_completions(config_file, start_of_line, start, end_of_line)

548

549 return self.key_type.get_completions(config_file, start_of_line, start, end_of_line)

550

551 def expand_value(self, config_file: 'ConfigFile', values: 'Mapping[T_key, T_val]', format_spec: str) -> str:

552 '''

553 :paramref:`~confattr.formatters.Dict.expand_value.format_spec` supports the following features:

554

555 - Get a single value, e.g. ``[key1]`` expands to the value corresponding to ``key1``, a :class:`KeyError` is raised if ``key1`` is not contained in the dict

556 - Get a single value or a default value, e.g. ``[key1|default]`` expands to the value corresponding to ``key1`` or to ``default`` if ``key1`` is not contained

557 - Get values with their corresponding keys, e.g. ``{key1,key2}`` expands to ``key1:val1,key2:val2``, if a key is not contained it is skipped

558 - Filter out elements, e.g. ``{^key1}`` expands to all ``key:val`` pairs except for ``key1``

559 - Get the length, ``len`` expands to the number of items

560

561 To any of the above you can append another format_spec after a colon to specify how to format the items/the length.

562 '''

563 m = re.match(r'(\[(?P<key>[^]|]+)(\|(?P<default>[^]]+))?\]|\{\^(?P<filter>[^}]+)\}|\{(?P<select>[^}]*)\}|(?P<func>[^[{:]+))(:(?P<format_spec>.*))?$', format_spec)

564 if m is None:

565 raise ValueError('Invalid format_spec for dict: %r' % format_spec)

566

567 item_format_spec = m.group('format_spec') or ''

568

569 key = m.group('key')

570 if key:

571 default = m.group('default')

572 return self.expand_single_value(config_file, values, key, default, item_format_spec)

573

574 keys_filter = m.group('filter')

575 if keys_filter:

576 return self.expand_filter(config_file, values, keys_filter, item_format_spec)

577

578 keys_select = m.group('select')

579 if keys_select:

580 return self.expand_select(config_file, values, keys_select, item_format_spec)

581

582 func = m.group('func')

583 if func == 'len':

584 return self.expand_length(config_file, values, item_format_spec)

585

586 raise ValueError('Invalid format_spec for dict: %r' % format_spec)

587

588 def expand_single_value(self, config_file: 'ConfigFile', values: 'Mapping[T_key, T_val]', key: str, default: 'str|None', item_format_spec: str) -> str:

589 '''

590 Is called by :meth:`~confattr.formatters.Dict.expand_value` if :paramref:`~confattr.formatters.Dict.expand_value.format_spec` has the pattern ``[key]`` or ``[key|default]``.

591 '''

592 parsed_key = self.key_type.parse_value(config_file, key)

593 try:

594 v = values[parsed_key]

595 except KeyError:

596 if default is not None:

597 return default

598 # The message of a KeyError is the repr of the missing key, nothing more.

599 # Therefore I am raising a new exception with a more descriptive message.

600 # I am not using KeyError because that takes the repr of the argument.

601 raise LookupError(f"key {key!r} is not contained in {self.config_key!r}")

602

603 if not item_format_spec:

604 return self.value_type.format_value(config_file, v)

605 return format(v, item_format_spec)

606

607 def expand_filter(self, config_file: 'ConfigFile', values: 'Mapping[T_key, T_val]', keys_filter: str, item_format_spec: str) -> str:

608 '''

609 Is called by :meth:`~confattr.formatters.Dict.expand_value` if :paramref:`~confattr.formatters.Dict.expand_value.format_spec` has the pattern ``{^key1,key2}``.

610 '''

611 parsed_filter_keys = {self.key_type.parse_value(config_file, key) for key in keys_filter.split(',')}

612 values = {k: v for k,v in values.items() if k not in parsed_filter_keys}

613 return self.expand_selected(config_file, values, item_format_spec)

614

615 def expand_select(self, config_file: 'ConfigFile', values: 'Mapping[T_key, T_val]', keys_select: str, item_format_spec: str) -> str:

616 '''

617 Is called by :meth:`~confattr.formatters.Dict.expand_value` if :paramref:`~confattr.formatters.Dict.expand_value.format_spec` has the pattern ``{key1,key2}``.

618 '''

619 parsed_select_keys = {self.key_type.parse_value(config_file, key) for key in keys_select.split(',')}

620 values = {k: v for k,v in values.items() if k in parsed_select_keys}

621 return self.expand_selected(config_file, values, item_format_spec)

622

623 def expand_selected(self, config_file: 'ConfigFile', values: 'Mapping[T_key, T_val]', item_format_spec: str) -> str:

624 '''

625 Is called by :meth:`~confattr.formatters.Dict.expand_filter` and :meth:`~confattr.formatters.Dict.expand_select` to do the formatting of the filtered/selected values

626 '''

627 if not item_format_spec:

628 return self.format_value(config_file, values)

629 return config_file.ITEM_SEP.join(self.key_type.format_value(config_file, k) + config_file.KEY_SEP + format(v, item_format_spec) for k, v in values.items())

630

631 def expand_length(self, config_file: 'ConfigFile', values: 'Collection[T]', int_format_spec: str) -> str:

632 '''

633 Is called by :meth:`~confattr.formatters.Dict.expand_value` if :paramref:`~confattr.formatters.Dict.expand_value.format_spec` is ``len``.

634 '''

635 return format(len(values), int_format_spec)