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 config_key: 'str|None' = None

45

46 @abc.abstractmethod

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

48 raise NotImplementedError()

49

50 @abc.abstractmethod

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

52 '''

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

54 :param value: The value to be formatted

55 :param format_spec: A format specifier

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

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

58 '''

59 raise NotImplementedError()

60

61 @abc.abstractmethod

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

63 raise NotImplementedError()

64

65 @abc.abstractmethod

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

67 raise NotImplementedError()

68

69 @abc.abstractmethod

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

71 raise NotImplementedError()

72

73 @abc.abstractmethod

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

75 '''

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

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

78 '''

79 raise NotImplementedError()

80

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

82 '''

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

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

85 This method must not be called more than once.

86

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

88 '''

89 if self.config_key:

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

91 self.config_key = config_key

92

93

94 class CopyableAbstractFormatter(AbstractFormatter[T]):

95

96 @abc.abstractmethod

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

98 raise NotImplementedError()

99

100

101 class Primitive(CopyableAbstractFormatter[T]):

102

103 PATTERN_ONE_OF = "one of {}"

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

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

106

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

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

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

110 int : '''\

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

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

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

114 #bool,

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

116 }

117

118

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

120 type_name: 'str|None'

121

122 #: The unit of a number

123 unit: 'str|None'

124

125 #: :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`

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

127

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

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

130

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

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

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

134

135 self.type = type

136 self.type_name = type_name

137 self.allowed_values = allowed_values

138 self.unit = unit

139

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

141 out = copy.copy(self)

142 out.config_key = None

143 return out

144

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

146 if isinstance(self.allowed_values, dict):

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

148 if val == value:

149 return key

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

151

152 if isinstance(value, str):

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

154

155 return format_primitive_value(value)

156

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

158 '''

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

160 '''

161 return format(value, format_spec)

162

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

164 if isinstance(self.allowed_values, dict):

165 try:

166 return self.allowed_values[value]

167 except KeyError:

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

169 elif self.type is str:

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

171 out = typing.cast(T, value)

172 elif self.type is int:

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

174 elif self.type is float:

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

176 elif self.type is bool:

177 if value == VALUE_TRUE:

178 out = typing.cast(T, True)

179 elif value == VALUE_FALSE:

180 out = typing.cast(T, False)

181 else:

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

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

184 for i in self.type:

185 enum_item = typing.cast(T, i)

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

187 out = enum_item

188 break

189 else:

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

191 else:

192 try:

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

194 except Exception as e:

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

196

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

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

199 return out

200

201

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

203 '''

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

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

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

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

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

209 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`.

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

211

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

213 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`.

214 '''

215 if plural:

216 article = False

217

218 if not self.type_name:

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

220 if out:

221 return out

222

223 out = self.get_type_name()

224 if self.unit:

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

226 if article:

227 out = self.format_indefinite_singular_article(out)

228 return out

229

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

231 allowed_values = self.get_allowed_values()

232 if not allowed_values:

233 return None

234

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

236 if article:

237 out = self.PATTERN_ONE_OF.format(out)

238 if self.unit:

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

240 return out

241

242 def get_type_name(self) -> str:

243 '''

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

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

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

247

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

249 '''

250 if self.type_name:

251 return self.type_name

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

253

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

255 '''

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

257 '''

258 return ', '.join(names)

259

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

261 '''

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

263 Alternatively this method can be overridden.

264 This also gives the possibility to omit the article.

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

266

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

268 '''

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

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

271 if not article:

272 return type_name

273 assert isinstance(article, str)

274 return article + ' ' + type_name

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

276 return 'an ' + type_name

277 return 'a ' + type_name

278

279

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

281 '''

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

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

284

285 For example the help for an int might be:

286

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

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

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

290

291 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`.

292

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

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

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

296 '''

297

298 if self.type_name:

299 allowed_values = self.format_allowed_values(config_file)

300 if not allowed_values:

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

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

303

304 if self.type in self.help_dict:

305 return self.help_dict[self.type]

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

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

308 if not isinstance(out, str):

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

310 return out

311 elif self.get_allowed_values():

312 return None

313 else:

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

315

316

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

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

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

320 return start_of_line, completions, end_of_line

321

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

323 if isinstance(self.allowed_values, dict):

324 return self.allowed_values.values()

325 if self.allowed_values:

326 return self.allowed_values

327 if self.type is bool:

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

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

330 return self.type

331 return ()

332

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

334 return (self,)

335

336 class Hex(Primitive[int]):

337

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

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

340

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

342 return '%X' % value

343

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

345 return int(value, base=16)

346

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

348 out = 'hexadecimal number'

349 if plural:

350 out += 's'

351 elif article:

352 out = 'a ' + out

353 return out

354

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

356 return None

357

358

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

360

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

362 self.item_type = item_type

363

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

365 return values.split(config_file.ITEM_SEP)

366

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

368 if config_file.ITEM_SEP in start:

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

370 start_of_line += first + config_file.ITEM_SEP

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

372

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

374 return (self.item_type,)

375

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

377 super().set_config_key(config_key)

378 self.item_type.set_config_key(config_key)

379

380

381 # ------- expand ------

382

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

384 '''

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

386

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

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

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

390

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

392 '''

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

394 if m is None:

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

396

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

398 func = m.group('func')

399 if func == 'len':

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

401 elif func:

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

403

404 exclude = m.group('exclude')

405 if exclude:

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

407

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

409

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

411 return format(len(values), int_format_spec)

412

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

414 if func == 'min':

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

416 elif func == 'max':

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

418 else:

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

420

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

422

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

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

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

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

427

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

429 if not item_format_spec:

430 return self.format_value(config_file, values)

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

432

433 class List(AbstractCollection[T]):

434

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

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

437

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

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

440

441 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

442 '''

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

444

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

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

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

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

449

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

451

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

453 '''

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

455 if m is None:

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

457

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

459 indices = m.group('indices')

460 assert isinstance(indices, str)

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

462

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

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

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

466

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

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

469 yield self.parse_slice(s)

470

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

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

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

474 i = sl[0]

475 return slice(i, i+1)

476 return slice(*sl)

477

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

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

480

481 class Set(AbstractCollection[T]):

482

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

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

485

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

487 try:

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

489 except TypeError:

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

491

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

493

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

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

496

497

498 T_key = typing.TypeVar('T_key')

499 T_val = typing.TypeVar('T_val')

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

501

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

503 self.key_type = key_type

504 self.value_type = value_type

505

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

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

508

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

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

511

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

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

514

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

516 return values.split(config_file.ITEM_SEP)

517

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

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

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

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

522 return key, val

523

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

525 return (self.key_type, self.value_type)

526

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

528 if config_file.ITEM_SEP in start:

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

530 start_of_line += first + config_file.ITEM_SEP

531 if config_file.KEY_SEP in start:

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

533 start_of_line += first + config_file.KEY_SEP

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

535

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

537

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

539 '''

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

541

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

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

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

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

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

547

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

549 '''

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

551 if m is None:

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

553

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

555

556 key = m.group('key')

557 if key:

558 default = m.group('default')

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

560

561 keys_filter = m.group('filter')

562 if keys_filter:

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

564

565 keys_select = m.group('select')

566 if keys_select:

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

568

569 func = m.group('func')

570 if func == 'len':

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

572

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

574

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

576 '''

577 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]``.

578 '''

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

580 try:

581 v = values[parsed_key]

582 except KeyError:

583 if default is not None:

584 return default

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

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

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

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

589

590 if not item_format_spec:

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

592 return format(v, item_format_spec)

593

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

595 '''

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

597 '''

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

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

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

601

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

603 '''

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

605 '''

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

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

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

609

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

611 '''

612 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

613 '''

614 if not item_format_spec:

615 return self.format_value(config_file, values)

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

617

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

619 '''

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

621 '''

622 return format(len(values), int_format_spec)