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

333 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-06-10 20:18 +0200

1#!/usr/bin/env python3 

2 

3import re 

4import copy 

5import abc 

6import enum 

7import typing 

8import builtins 

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

10 

11if typing.TYPE_CHECKING: 

12 from .configfile import ConfigFile 

13 from typing_extensions import Self 

14 

15try: 

16 Collection = typing.Collection 

17except: # pragma: no cover 

18 from collections.abc import Collection 

19 

20 

21TYPES_REQUIRING_UNIT = {int, float} 

22 

23VALUE_TRUE = 'true' 

24VALUE_FALSE = 'false' 

25 

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

40T = typing.TypeVar('T') 

41 

42class 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 

94class CopyableAbstractFormatter(AbstractFormatter[T]): 

95 

96 @abc.abstractmethod 

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

98 raise NotImplementedError() 

99 

100 

101class 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 

336class 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 

359class 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 

433class 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 

481class 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 

498T_key = typing.TypeVar('T_key') 

499T_val = typing.TypeVar('T_val') 

500class 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)