Coverage for yuio / config.py: 95%

476 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-05 11:41 +0000

1# Yuio project, MIT license. 

2# 

3# https://github.com/taminomara/yuio/ 

4# 

5# You're free to copy this file to your project and edit it for your needs, 

6# just keep this copyright line please :3 

7 

8""" 

9This module provides a base class for configs that can be loaded from 

10files, environment variables or command line arguments (via :mod:`yuio.app`). 

11 

12Derive your config from the :class:`Config` base class. Inside of its body, 

13define config fields using type annotations, just like :mod:`dataclasses`: 

14 

15.. code-block:: python 

16 

17 class AppConfig(Config): 

18 #: trained model to execute 

19 model: pathlib.Path 

20 

21 #: input data for the model 

22 data: pathlib.Path 

23 

24 #: enable or disable gpu 

25 use_gpu: bool = True 

26 

27Then use config's constructors and the :meth:`~Config.update` method 

28to load it from various sources:: 

29 

30 # Load config from a file. 

31 config = AppConfig.load_from_json_file("~/.my_app_cfg.json") 

32 

33 # Update config with values from env. 

34 config.update(AppConfig.load_from_env()) 

35 

36 

37Config base class 

38----------------- 

39 

40.. autoclass:: Config 

41 

42 

43Advanced field configuration 

44---------------------------- 

45 

46By default, :class:`Config` infers names for env variables and flags, 

47appropriate parsers, and other things from field's name, type hint, and comments. 

48 

49If you need to override them, you can use :func:`yuio.app.field` 

50and :func:`yuio.app.inline` functions (also available from :mod:`yuio.config`): 

51 

52.. code-block:: python 

53 

54 class AppConfig(Config): 

55 model: pathlib.Path | None = field( 

56 default=None, 

57 help="trained model to execute", 

58 ) 

59 

60 

61Nesting configs 

62--------------- 

63 

64You can nest configs to achieve modularity: 

65 

66.. code-block:: python 

67 

68 class ExecutorConfig(Config): 

69 #: number of threads to use 

70 threads: int 

71 

72 #: enable or disable gpu 

73 use_gpu: bool = True 

74 

75 

76 class AppConfig(Config): 

77 #: executor parameters 

78 executor: ExecutorConfig 

79 

80 #: trained model to execute 

81 model: pathlib.Path 

82 

83To initialise a nested config, pass either an instance of if 

84or a dict with its variables to the config's constructor: 

85 

86.. code-block:: python 

87 

88 # The following lines are equivalent: 

89 config = AppConfig(executor=ExecutorConfig(threads=16)) 

90 config = AppConfig(executor={"threads": 16}) 

91 # ...although type checkers will complain about dict =( 

92 

93 

94Parsing environment variables 

95----------------------------- 

96 

97You can load config from environment through :meth:`~Config.load_from_env`. 

98 

99Names of environment variables are just capitalized field names. 

100Use the :func:`yuio.app.field` function to override them: 

101 

102.. code-block:: python 

103 

104 class KillCmdConfig(Config): 

105 # Will be loaded from `SIGNAL`. 

106 signal: int 

107 

108 # Will be loaded from `PROCESS_ID`. 

109 pid: int = field(env="PROCESS_ID") 

110 

111In nested configs, environment variable names are prefixed with name 

112of a field that contains the nested config: 

113 

114.. code-block:: python 

115 

116 class BigConfig(Config): 

117 # `kill_cmd.signal` will be loaded from `KILL_CMD_SIGNAL`. 

118 kill_cmd: KillCmdConfig 

119 

120 # `kill_cmd_2.signal` will be loaded from `KILL_SIGNAL`. 

121 kill_cmd_2: KillCmdConfig = field(env="KILL") 

122 

123 # `kill_cmd_3.signal` will be loaded from `SIGNAL`. 

124 kill_cmd_3: KillCmdConfig = field(env="") 

125 

126You can also disable loading a field from an environment altogether: 

127 

128.. code-block:: python 

129 

130 class KillCmdConfig(Config): 

131 # Will not be loaded from env. 

132 pid: int = field(env=yuio.DISABLED) 

133 

134To prefix all variable names with some string, pass the `prefix` parameter 

135to the :meth:`~Config.load_from_env` function: 

136 

137.. code-block:: python 

138 

139 # config.kill_cmd.field will be loaded 

140 # from `MY_APP_KILL_CMD_SIGNAL` 

141 config = BigConfig.load_from_env("MY_APP") 

142 

143 

144Parsing config files 

145-------------------- 

146 

147You can load config from structured config files, 

148such as `json`, `yaml` or `toml`: 

149 

150.. skip: next 

151 

152.. code-block:: python 

153 

154 class ExecutorConfig(Config): 

155 threads: int 

156 use_gpu: bool = True 

157 

158 

159 class AppConfig(Config): 

160 executor: ExecutorConfig 

161 model: pathlib.Path 

162 

163 

164 config = AppConfig.load_from_json_file("~/.my_app_cfg.json") 

165 

166In this example, contents of the above config would be: 

167 

168.. code-block:: json 

169 

170 { 

171 "executor": { 

172 "threads": 16, 

173 "use_gpu": true 

174 }, 

175 "model": "/path/to/model" 

176 } 

177 

178Note that, unlike with environment variables, 

179there is no way to inline nested configs. 

180 

181 

182Additional config validation 

183---------------------------- 

184 

185If you have invariants that can't be captured with type system, 

186you can override :meth:`~Config.validate_config`. This method will be called 

187every time you load a config from file, arguments or environment: 

188 

189.. code-block:: python 

190 

191 class DocGenConfig(Config): 

192 categories: list[str] = ["quickstart", "api_reference"] 

193 category_names: dict[str, str] = {"deep_dive": "Deep Dive"} 

194 

195 def validate_config(self): 

196 for category in self.category_names: 

197 if category not in self.categories: 

198 raise yuio.parse.ParsingError(f"unknown category {category}") 

199 

200 

201Merging configs 

202--------------- 

203 

204Configs are specially designed to be merge-able. The basic pattern is to create 

205an empty config instance, then :meth:`~Config.update` it with every config source: 

206 

207.. skip: next 

208 

209.. code-block:: python 

210 

211 config = AppConfig() 

212 config.update(AppConfig.load_from_json_file("~/.my_app_cfg.json")) 

213 config.update(AppConfig.load_from_env()) 

214 # ...and so on. 

215 

216The :meth:`~Config.update` function ignores default values, and only overrides 

217keys that were actually configured. 

218 

219If you need a more complex update behavior, you can add a merge function for a field: 

220 

221.. code-block:: python 

222 

223 class AppConfig(Config): 

224 plugins: list[str] = field( 

225 default=[], 

226 merge=lambda left, right: [*left, *right], 

227 ) 

228 

229Here, whenever we :meth:`~Config.update` ``AppConfig``, ``plugins`` from both instances 

230will be concatenated. 

231 

232.. warning:: 

233 

234 Merge function shouldn't mutate its arguments. 

235 It should produce a new value instead. 

236 

237.. warning:: 

238 

239 Merge function will not be called for default value. It's advisable to keep the 

240 default value empty, and add the actual default to the initial empty config: 

241 

242 .. skip: next 

243 

244 .. code-block:: python 

245 

246 config = AppConfig(plugins=["markdown", "rst"]) 

247 config.update(...) 

248 

249 

250Re-imports 

251---------- 

252 

253 

254.. function:: field 

255 :no-index: 

256 

257 Alias of :obj:`yuio.app.field` 

258 

259.. function:: inline 

260 :no-index: 

261 

262 Alias of :obj:`yuio.app.inline` 

263 

264.. function:: positional 

265 :no-index: 

266 

267 Alias of :obj:`yuio.app.positional` 

268 

269.. function:: bool_option 

270 :no-index: 

271 

272 Alias of :obj:`yuio.app.bool_option` 

273 

274.. function:: count_option 

275 :no-index: 

276 

277 Alias of :obj:`yuio.app.count_option` 

278 

279.. function:: parse_many_option 

280 :no-index: 

281 

282 Alias of :obj:`yuio.app.parse_many_option` 

283 

284.. function:: parse_one_option 

285 :no-index: 

286 

287 Alias of :obj:`yuio.app.parse_one_option` 

288 

289.. function:: store_const_option 

290 :no-index: 

291 

292 Alias of :obj:`yuio.app.store_const_option` 

293 

294.. function:: store_false_option 

295 :no-index: 

296 

297 Alias of :obj:`yuio.app.store_false_option` 

298 

299.. function:: store_true_option 

300 :no-index: 

301 

302 Alias of :obj:`yuio.app.store_true_option` 

303 

304.. type:: HelpGroup 

305 :no-index: 

306 

307 Alias of :obj:`yuio.cli.HelpGroup`. 

308 

309.. type:: MutuallyExclusiveGroup 

310 :no-index: 

311 

312 Alias of :obj:`yuio.cli.MutuallyExclusiveGroup`. 

313 

314.. type:: OptionCtor 

315 :no-index: 

316 

317 Alias of :obj:`yuio.app.OptionCtor`. 

318 

319.. type:: OptionSettings 

320 :no-index: 

321 

322 Alias of :obj:`yuio.app.OptionSettings`. 

323 

324.. data:: MISC_GROUP 

325 :no-index: 

326 

327 Alias of :obj:`yuio.cli.MISC_GROUP`. 

328 

329.. data:: OPTS_GROUP 

330 :no-index: 

331 

332 Alias of :obj:`yuio.cli.OPTS_GROUP`. 

333 

334.. data:: SUBCOMMANDS_GROUP 

335 :no-index: 

336 

337 Alias of :obj:`yuio.cli.SUBCOMMANDS_GROUP`. 

338 

339""" 

340 

341from __future__ import annotations 

342 

343import json 

344import os 

345import pathlib 

346import textwrap 

347import types 

348from dataclasses import dataclass 

349 

350import yuio 

351import yuio.cli 

352import yuio.complete 

353import yuio.json_schema 

354import yuio.parse 

355import yuio.string 

356from yuio.cli import ( 

357 MISC_GROUP, 

358 OPTS_GROUP, 

359 SUBCOMMANDS_GROUP, 

360 HelpGroup, 

361 MutuallyExclusiveGroup, 

362) 

363from yuio.util import _find_docs 

364 

365import yuio._typing_ext as _tx 

366from typing import TYPE_CHECKING 

367 

368if TYPE_CHECKING: 

369 import typing_extensions as _t 

370else: 

371 from yuio import _typing as _t 

372 

373__all__ = [ 

374 "MISC_GROUP", 

375 "OPTS_GROUP", 

376 "SUBCOMMANDS_GROUP", 

377 "Config", 

378 "HelpGroup", 

379 "MutuallyExclusiveGroup", 

380 "OptionCtor", 

381 "OptionSettings", 

382 "bool_option", 

383 "count_option", 

384 "field", 

385 "inline", 

386 "parse_many_option", 

387 "parse_one_option", 

388 "positional", 

389 "store_const_option", 

390 "store_false_option", 

391 "store_true_option", 

392] 

393 

394T = _t.TypeVar("T") 

395Cfg = _t.TypeVar("Cfg", bound="Config") 

396 

397 

398@dataclass(frozen=True, slots=True) 

399class _FieldSettings: 

400 default: _t.Any 

401 parser: yuio.parse.Parser[_t.Any] | None = None 

402 help: str | yuio.Disabled | None = None 

403 env: str | yuio.Disabled | None = None 

404 flags: str | list[str] | yuio.Positional | yuio.Disabled | None = None 

405 completer: yuio.complete.Completer | None | yuio.Missing = yuio.MISSING 

406 metavar: str | None = None 

407 required: bool | None = None 

408 merge: _t.Callable[[_t.Any, _t.Any], _t.Any] | None = None 

409 mutex_group: MutuallyExclusiveGroup | None = None 

410 help_group: HelpGroup | None = None 

411 usage: yuio.Group | bool | None = None 

412 show_if_inherited: bool | None = None 

413 option_ctor: _t.Callable[[OptionSettings], _t.Any] | None = None 

414 

415 def _update_defaults( 

416 self, 

417 qualname: str, 

418 name: str, 

419 ty_with_extras: _t.Any, 

420 parsed_help: str | None, 

421 allow_positionals: bool, 

422 ) -> _Field: 

423 ty = ty_with_extras 

424 while _t.get_origin(ty) is _t.Annotated: 

425 ty = _t.get_args(ty)[0] 

426 is_subconfig = isinstance(ty, type) and issubclass(ty, Config) 

427 

428 help: str | yuio.Disabled 

429 if self.help is not None: 

430 help = self.help 

431 elif parsed_help is not None: 

432 help = parsed_help 

433 elif is_subconfig and ty.__doc__: 

434 help = ty.__doc__ 

435 else: 

436 help = "" 

437 

438 env: str | yuio.Disabled 

439 if self.env is not None: 

440 env = self.env 

441 else: 

442 env = name.upper() 

443 if env == "" and not is_subconfig: 

444 raise TypeError(f"{qualname} got an empty env variable name") 

445 

446 flags: list[str] | yuio.Positional | yuio.Disabled 

447 if self.flags is yuio.DISABLED: 

448 flags = self.flags 

449 elif self.flags is yuio.POSITIONAL: 

450 if not allow_positionals: 

451 raise TypeError( 

452 f"{qualname}: positional arguments are not allowed in configs" 

453 ) 

454 if is_subconfig: 

455 raise TypeError( 

456 f"error in {qualname}: nested configs can't be positional" 

457 ) 

458 flags = self.flags 

459 elif self.flags is None: 

460 flags = ["--" + name.replace("_", "-")] 

461 else: 

462 if isinstance(self.flags, str): 

463 flags = self.flags.split() or [""] 

464 else: 

465 flags = self.flags 

466 

467 if is_subconfig: 

468 if not flags: 

469 raise TypeError( 

470 f"error in {qualname}: nested configs should have exactly one flag; " 

471 "to disable prefixing, pass an empty string as a flag" 

472 ) 

473 if len(flags) > 1: 

474 raise TypeError( 

475 f"error in {qualname}: nested configs can't have multiple flags" 

476 ) 

477 if flags[0]: 

478 if not flags[0].startswith("--"): 

479 raise TypeError( 

480 f"error in {qualname}: nested configs can't have a short flag" 

481 ) 

482 try: 

483 yuio.cli._check_flag(flags[0]) 

484 except TypeError as e: 

485 raise TypeError(f"error in {qualname}: {e}") from None 

486 else: 

487 if not flags: 

488 raise TypeError(f"{qualname} should have at least one flag") 

489 for flag in flags: 

490 try: 

491 yuio.cli._check_flag(flag) 

492 except TypeError as e: 

493 raise TypeError(f"error in {qualname}: {e}") from None 

494 

495 default = self.default 

496 if is_subconfig and default is not yuio.MISSING: 

497 raise TypeError(f"error in {qualname}: nested configs can't have defaults") 

498 

499 parser = self.parser 

500 if is_subconfig and parser is not None: 

501 raise TypeError(f"error in {qualname}: nested configs can't have parsers") 

502 elif not is_subconfig and parser is None: 

503 try: 

504 parser = yuio.parse.from_type_hint(ty_with_extras) 

505 except TypeError as e: 

506 raise TypeError( 

507 f"can't derive parser for {qualname}:\n" 

508 + textwrap.indent(str(e), " ") 

509 ) from None 

510 if parser is not None: 

511 origin = _t.get_origin(ty) 

512 args = _t.get_args(ty) 

513 is_optional = ( 

514 default is None or _tx.is_union(origin) and types.NoneType in args 

515 ) 

516 if is_optional and not yuio.parse._is_optional_parser(parser): 

517 parser = yuio.parse.Optional(parser) 

518 completer = self.completer 

519 metavar = self.metavar 

520 if not metavar and flags is yuio.POSITIONAL: 

521 metavar = f"<{name.replace('_', '-')}>" 

522 if completer is not None or metavar is not None: 

523 parser = yuio.parse.WithMeta(parser, desc=metavar, completer=completer) 

524 

525 required = self.required 

526 if is_subconfig and required: 

527 raise TypeError(f"error in {qualname}: nested configs can't be required") 

528 if required is None: 

529 if is_subconfig: 

530 required = False 

531 elif allow_positionals: 

532 required = default is yuio.MISSING 

533 else: 

534 required = False 

535 

536 merge = self.merge 

537 if is_subconfig and merge is not None: 

538 raise TypeError( 

539 f"error in {qualname}: nested configs can't have merge function" 

540 ) 

541 

542 mutex_group = self.mutex_group 

543 if is_subconfig and mutex_group is not None: 

544 raise TypeError( 

545 f"error in {qualname}: nested configs can't be a part " 

546 "of a mutually exclusive group" 

547 ) 

548 if flags is yuio.POSITIONAL and mutex_group is not None: 

549 raise TypeError( 

550 f"error in {qualname}: positional arguments can't appear in mutually exclusive groups" 

551 ) 

552 

553 usage = self.usage 

554 

555 show_if_inherited = self.show_if_inherited 

556 

557 help_group = self.help_group 

558 

559 option_ctor = self.option_ctor 

560 if option_ctor is not None and is_subconfig: 

561 raise TypeError( 

562 f"error in {qualname}: nested configs can't have option constructors" 

563 ) 

564 

565 return _Field( 

566 default, 

567 parser, 

568 help, 

569 env, 

570 flags, 

571 is_subconfig, 

572 ty, 

573 required, 

574 merge, 

575 mutex_group, 

576 help_group, 

577 usage, 

578 show_if_inherited, 

579 option_ctor, 

580 ) 

581 

582 

583@dataclass(frozen=True, slots=True) 

584class _Field: 

585 default: _t.Any 

586 parser: yuio.parse.Parser[_t.Any] | None 

587 help: str | yuio.Disabled 

588 env: str | yuio.Disabled 

589 flags: list[str] | yuio.Positional | yuio.Disabled 

590 is_subconfig: bool 

591 ty: type 

592 required: bool 

593 merge: _t.Callable[[_t.Any, _t.Any], _t.Any] | None 

594 mutex_group: MutuallyExclusiveGroup | None 

595 help_group: HelpGroup | None 

596 usage: yuio.Group | bool | None 

597 show_if_inherited: bool | None = None 

598 option_ctor: _t.Callable[[OptionSettings], _t.Any] | None = None 

599 

600 

601@_t.overload 

602def field( 

603 *, 

604 completer: yuio.complete.Completer | None | yuio.Missing = yuio.MISSING, 

605 metavar: str | None = None, 

606 help: str | yuio.Disabled | None = None, 

607 env: str | yuio.Disabled | None = None, 

608 flags: str | list[str] | yuio.Positional | yuio.Disabled | None = None, 

609 required: bool | None = None, 

610 mutex_group: MutuallyExclusiveGroup | None = None, 

611 help_group: HelpGroup | None = None, 

612 show_if_inherited: bool | None = None, 

613 usage: yuio.Group | bool | None = None, 

614) -> _t.Any: ... 

615@_t.overload 

616def field( 

617 *, 

618 default: None, 

619 parser: yuio.parse.Parser[T] | None = None, 

620 help: str | yuio.Disabled | None = None, 

621 env: str | yuio.Disabled | None = None, 

622 flags: str | list[str] | yuio.Positional | yuio.Disabled | None = None, 

623 required: bool | None = None, 

624 completer: yuio.complete.Completer | None | yuio.Missing = yuio.MISSING, 

625 metavar: str | None = None, 

626 merge: _t.Callable[[T, T], T] | None = None, 

627 mutex_group: MutuallyExclusiveGroup | None = None, 

628 help_group: HelpGroup | None = None, 

629 usage: yuio.Group | bool | None = None, 

630 show_if_inherited: bool | None = None, 

631 option_ctor: OptionCtor[T] | None = None, 

632) -> T | None: ... 

633@_t.overload 

634def field( 

635 *, 

636 default: T | yuio.Missing = yuio.MISSING, 

637 parser: yuio.parse.Parser[T] | None = None, 

638 help: str | yuio.Disabled | None = None, 

639 env: str | yuio.Disabled | None = None, 

640 flags: str | list[str] | yuio.Positional | yuio.Disabled | None = None, 

641 required: bool | None = None, 

642 completer: yuio.complete.Completer | None | yuio.Missing = yuio.MISSING, 

643 metavar: str | None = None, 

644 merge: _t.Callable[[T, T], T] | None = None, 

645 mutex_group: MutuallyExclusiveGroup | None = None, 

646 help_group: HelpGroup | None = None, 

647 usage: yuio.Group | bool | None = None, 

648 show_if_inherited: bool | None = None, 

649 option_ctor: OptionCtor[T] | None = None, 

650) -> T: ... 

651def field( 

652 *, 

653 default: _t.Any = yuio.MISSING, 

654 parser: yuio.parse.Parser[_t.Any] | None = None, 

655 help: str | yuio.Disabled | None = None, 

656 env: str | yuio.Disabled | None = None, 

657 flags: str | list[str] | yuio.Positional | yuio.Disabled | None = None, 

658 required: bool | None = None, 

659 completer: yuio.complete.Completer | None | yuio.Missing = yuio.MISSING, 

660 metavar: str | None = None, 

661 merge: _t.Callable[[_t.Any, _t.Any], _t.Any] | None = None, 

662 mutex_group: MutuallyExclusiveGroup | None = None, 

663 help_group: HelpGroup | None = None, 

664 usage: yuio.Group | bool | None = None, 

665 show_if_inherited: bool | None = None, 

666 option_ctor: _t.Callable[..., _t.Any] | None = None, 

667) -> _t.Any: 

668 """ 

669 Field descriptor, used for additional configuration of CLI options 

670 and config fields. 

671 

672 :param default: 

673 default value for the field or CLI option. 

674 :param parser: 

675 parser that will be used to parse config values and CLI options. 

676 :param help: 

677 help message that will be used in CLI option description, 

678 formatted using Markdown (see :mod:`yuio.md`). 

679 

680 Pass :data:`yuio.DISABLED` to remove this field from CLI help. 

681 

682 In sub-config fields, controls grouping of fields; pass an empty string 

683 to disable grouping. 

684 :param env: 

685 specifies name of environment variable that will be used if loading config 

686 from environment. 

687 

688 Pass :data:`~yuio.DISABLED` to disable loading this field form environment. 

689 

690 In sub-config fields, controls prefix for all environment variables within 

691 this sub-config; pass an empty string to disable prefixing. 

692 :param flags: 

693 list of names (or a single name) of CLI flags that will be used for this field. 

694 

695 In configs, pass :data:`~yuio.DISABLED` to disable loading this field form CLI arguments. 

696 

697 In apps, pass :data:`~yuio.POSITIONAL` to make this argument positional. 

698 

699 In sub-config fields, controls prefix for all flags withing this sub-config; 

700 pass an empty string to disable prefixing. 

701 :param completer: 

702 completer that will be used for autocompletion in CLI. Using this option 

703 is equivalent to overriding `completer` with :class:`yuio.parse.WithMeta`. 

704 :param metavar: 

705 value description that will be used for CLI help messages. Using this option 

706 is equivalent to overriding `desc` with :class:`yuio.parse.WithMeta`. 

707 :param merge: 

708 defines how values of this field are merged when configs are updated. 

709 :param mutex_group: 

710 defines mutually exclusive group for this field. 

711 :param help_group: 

712 overrides group in which this field will be placed when generating CLI help 

713 message. 

714 :param usage: 

715 controls how this field renders in CLI usage section. 

716 

717 Pass :data:`False` to remove this field from usage. 

718 

719 Pass :class:`yuio.GROUP` to omit this field and add a single string 

720 ``<options>`` instead. 

721 

722 Setting `usage` on sub-config fields overrides default `usage` for all 

723 fields within this sub-config. 

724 :param option_ctor: 

725 this parameter is similar to :mod:`argparse`\\ 's ``action``: it allows 

726 overriding logic for handling CLI arguments by providing a custom 

727 :class:`~yuio.cli.Option` implementation. 

728 

729 `option_ctor` should be a callable which takes a single positional argument 

730 of type :class:`~yuio.app.OptionSettings`, and returns an instance 

731 of :class:`yuio.cli.Option`. 

732 :returns: 

733 a magic object that will be replaced with field's default value once a new 

734 config class is created. 

735 :example: 

736 In apps: 

737 

738 .. invisible-code-block: python 

739 

740 import yuio.app 

741 

742 .. code-block:: python 

743 

744 @yuio.app.app 

745 def main( 

746 # Will be loaded from `--input`. 

747 input: pathlib.Path | None = None, 

748 # Will be loaded from `-o` or `--output`. 

749 output: pathlib.Path | None = field(default=None, flags="-o --output"), 

750 ): ... 

751 

752 In configs: 

753 

754 .. code-block:: python 

755 

756 class AppConfig(Config): 

757 model: pathlib.Path | None = field( 

758 default=None, 

759 help="trained model to execute", 

760 ) 

761 

762 """ 

763 

764 return _FieldSettings( 

765 default=default, 

766 parser=parser, 

767 help=help, 

768 env=env, 

769 flags=flags, 

770 completer=completer, 

771 metavar=metavar, 

772 required=required, 

773 merge=merge, 

774 mutex_group=mutex_group, 

775 help_group=help_group, 

776 usage=usage, 

777 show_if_inherited=show_if_inherited, 

778 option_ctor=option_ctor, 

779 ) 

780 

781 

782def inline( 

783 help: str | yuio.Disabled | None = None, 

784 usage: yuio.Group | bool | None = None, 

785 show_if_inherited: bool | None = None, 

786 help_group: HelpGroup | None = None, 

787) -> _t.Any: 

788 """ 

789 A shortcut for inlining nested configs. 

790 

791 Equivalent to calling :func:`~yuio.app.field` with `env` and `flags` 

792 set to an empty string. 

793 

794 """ 

795 

796 return field( 

797 help=help, 

798 env="", 

799 flags="", 

800 usage=usage, 

801 show_if_inherited=show_if_inherited, 

802 help_group=help_group, 

803 ) 

804 

805 

806@_t.overload 

807def positional( 

808 *, 

809 help: str | yuio.Disabled | None = None, 

810 env: str | yuio.Disabled | None = None, 

811 completer: yuio.complete.Completer | None | yuio.Missing = yuio.MISSING, 

812 metavar: str | None = None, 

813 usage: yuio.Group | bool | None = None, 

814) -> _t.Any: ... 

815@_t.overload 

816def positional( 

817 *, 

818 default: None, 

819 parser: yuio.parse.Parser[T] | None = None, 

820 help: str | yuio.Disabled | None = None, 

821 env: str | yuio.Disabled | None = None, 

822 completer: yuio.complete.Completer | None | yuio.Missing = yuio.MISSING, 

823 metavar: str | None = None, 

824 usage: yuio.Group | bool | None = None, 

825) -> T | None: ... 

826@_t.overload 

827def positional( 

828 *, 

829 default: T | yuio.Missing = yuio.MISSING, 

830 parser: yuio.parse.Parser[T] | None = None, 

831 help: str | yuio.Disabled | None = None, 

832 env: str | yuio.Disabled | None = None, 

833 completer: yuio.complete.Completer | None | yuio.Missing = yuio.MISSING, 

834 metavar: str | None = None, 

835 usage: yuio.Group | bool | None = None, 

836) -> T: ... 

837def positional( 

838 *, 

839 default: _t.Any = yuio.MISSING, 

840 parser: yuio.parse.Parser[_t.Any] | None = None, 

841 help: str | yuio.Disabled | None = None, 

842 env: str | yuio.Disabled | None = None, 

843 completer: yuio.complete.Completer | None | yuio.Missing = yuio.MISSING, 

844 metavar: str | None = None, 

845 usage: yuio.Group | bool | None = None, 

846) -> _t.Any: 

847 """ 

848 A shortcut for adding a positional argument. 

849 

850 Equivalent to calling :func:`field` with `flags` set to :data:`~yuio.POSITIONAL`. 

851 

852 """ 

853 

854 return field( 

855 default=default, 

856 parser=parser, 

857 help=help, 

858 env=env, 

859 flags=yuio.POSITIONAL, 

860 completer=completer, 

861 metavar=metavar, 

862 usage=usage, 

863 ) 

864 

865 

866@_t.dataclass_transform( 

867 eq_default=False, 

868 order_default=False, 

869 kw_only_default=True, 

870 frozen_default=False, 

871 field_specifiers=(field, inline, positional), 

872) 

873class Config: 

874 """ 

875 Base class for configs. 

876 

877 Pass keyword args to set fields, or pass another config to copy it:: 

878 

879 Config(config1, config2, ..., field1=value1, ...) 

880 

881 Upon creation, all fields that aren't explicitly initialized 

882 and don't have defaults are considered missing. 

883 Accessing them will raise :class:`AttributeError`. 

884 

885 .. automethod:: update 

886 

887 .. automethod:: load_from_env 

888 

889 .. automethod:: load_from_json_file 

890 

891 .. automethod:: load_from_yaml_file 

892 

893 .. automethod:: load_from_toml_file 

894 

895 .. automethod:: load_from_parsed_file 

896 

897 .. automethod:: to_json_schema 

898 

899 .. automethod:: to_json_value 

900 

901 .. automethod:: validate_config 

902 

903 """ 

904 

905 @classmethod 

906 def __get_fields(cls) -> dict[str, _Field]: 

907 if cls.__fields is not None: 

908 return cls.__fields 

909 

910 if cls.__allow_positionals: 

911 docs = {} 

912 else: 

913 try: 

914 docs = _find_docs(cls) 

915 except Exception: 

916 yuio._logger.warning( 

917 "unable to get documentation for class %s.%s", 

918 cls.__module__, 

919 cls.__qualname__, 

920 ) 

921 docs = {} 

922 

923 fields = {} 

924 

925 for base in reversed(cls.__mro__): 

926 if base is not cls and hasattr(base, "_Config__get_fields"): 

927 fields.update(getattr(base, "_Config__get_fields")()) 

928 

929 try: 

930 types = _t.get_type_hints(cls, include_extras=True) 

931 except NameError as e: 

932 if "<locals>" in cls.__qualname__: 

933 raise NameError( 

934 f"{e}. " 

935 f"Note: forward references do not work inside functions " 

936 f"(see https://github.com/python/typing/issues/797)" 

937 ) from None 

938 raise # pragma: no cover 

939 

940 for name, field in cls.__gathered_fields.items(): 

941 if not isinstance(field, _FieldSettings): 

942 field = _FieldSettings(default=field) 

943 

944 fields[name] = field._update_defaults( 

945 f"{cls.__qualname__}.{name}", 

946 name, 

947 types[name], 

948 docs.get(name), 

949 cls.__allow_positionals, 

950 ) 

951 cls.__fields = fields 

952 

953 return fields 

954 

955 def __init_subclass__(cls, _allow_positionals=None, **kwargs): 

956 super().__init_subclass__(**kwargs) 

957 

958 if _allow_positionals is not None: 

959 cls.__allow_positionals: bool = _allow_positionals 

960 cls.__fields: dict[str, _Field] | None = None 

961 

962 cls.__gathered_fields: dict[str, _FieldSettings | _t.Any] = {} 

963 for name in cls.__annotations__: 

964 if not name.startswith("_"): 

965 cls.__gathered_fields[name] = cls.__dict__.get(name, yuio.MISSING) 

966 for name, value in cls.__dict__.items(): 

967 if isinstance(value, _FieldSettings) and name not in cls.__gathered_fields: 

968 qualname = f"{cls.__qualname__}.{name}" 

969 raise TypeError( 

970 f"error in {qualname}: field without annotations is not allowed" 

971 ) 

972 for name, value in cls.__gathered_fields.items(): 

973 if isinstance(value, _FieldSettings): 

974 value = value.default 

975 setattr(cls, name, value) 

976 

977 def __init__(self, *args: _t.Self | dict[str, _t.Any], **kwargs): 

978 for name, field in self.__get_fields().items(): 

979 if field.is_subconfig: 

980 setattr(self, name, field.ty()) 

981 

982 for arg in args: 

983 self.update(arg) 

984 

985 self.update(kwargs) 

986 

987 def update(self, other: _t.Self | dict[str, _t.Any], /): 

988 """ 

989 Update fields in this config with fields from another config. 

990 

991 This function is similar to :meth:`dict.update`. 

992 

993 Nested configs are updated recursively. 

994 

995 :param other: 

996 data for update. 

997 

998 """ 

999 

1000 if not other: 

1001 return 

1002 

1003 if isinstance(other, Config): 

1004 if ( 

1005 self.__class__ not in other.__class__.__mro__ 

1006 and other.__class__ not in self.__class__.__mro__ 

1007 ): 

1008 raise TypeError("updating from an incompatible config") 

1009 ns = other.__dict__ 

1010 elif isinstance(other, dict): 

1011 ns = other 

1012 for name in ns: 

1013 if name not in self.__get_fields(): 

1014 raise TypeError(f"unknown field: {name}") 

1015 else: 

1016 raise TypeError("expected a dict or a config class") 

1017 

1018 for name, field in self.__get_fields().items(): 

1019 if name in ns: 

1020 if field.is_subconfig: 

1021 getattr(self, name).update(ns[name]) 

1022 elif ns[name] is not yuio.MISSING: 

1023 if field.merge is not None and name in self.__dict__: 

1024 setattr(self, name, field.merge(getattr(self, name), ns[name])) 

1025 else: 

1026 setattr(self, name, ns[name]) 

1027 

1028 @classmethod 

1029 def load_from_env(cls, prefix: str = "") -> _t.Self: 

1030 """ 

1031 Load config from environment variables. 

1032 

1033 :param prefix: 

1034 if given, names of all environment variables will be prefixed with 

1035 this string and an underscore. 

1036 :returns: 

1037 a parsed config. 

1038 :raises: 

1039 :class:`~yuio.parse.ParsingError`. 

1040 

1041 """ 

1042 

1043 result = cls.__load_from_env(prefix) 

1044 result.validate_config() 

1045 return result 

1046 

1047 @classmethod 

1048 def __load_from_env(cls, prefix: str = "") -> _t.Self: 

1049 fields = {} 

1050 

1051 for name, field in cls.__get_fields().items(): 

1052 if field.env is yuio.DISABLED: 

1053 continue 

1054 

1055 if prefix and field.env: 

1056 env = f"{prefix}_{field.env}" 

1057 else: 

1058 env = f"{prefix}{field.env}" 

1059 

1060 if field.is_subconfig: 

1061 fields[name] = field.ty.load_from_env(prefix=env) 

1062 elif env in os.environ: 

1063 assert field.parser is not None 

1064 try: 

1065 fields[name] = field.parser.parse(os.environ[env]) 

1066 except yuio.parse.ParsingError as e: 

1067 raise yuio.parse.ParsingError( 

1068 "Can't parse environment variable `%s`:\n%s", 

1069 env, 

1070 yuio.string.Indent(e), 

1071 ) from None 

1072 

1073 return cls(**fields) 

1074 

1075 @classmethod 

1076 def _build_options(cls): 

1077 return cls.__build_options("", "", None, True, False) 

1078 

1079 @classmethod 

1080 def __build_options( 

1081 cls, 

1082 prefix: str, 

1083 dest_prefix: str, 

1084 help_group: yuio.cli.HelpGroup | None, 

1085 usage: yuio.Group | bool, 

1086 show_if_inherited: bool, 

1087 ) -> list[yuio.cli.Option[_t.Any]]: 

1088 options: list[yuio.cli.Option[_t.Any]] = [] 

1089 

1090 if prefix: 

1091 prefix += "-" 

1092 

1093 for name, field in cls.__get_fields().items(): 

1094 if field.flags is yuio.DISABLED: 

1095 continue 

1096 

1097 dest = dest_prefix + name 

1098 

1099 flags: list[str] | yuio.Positional 

1100 if prefix and field.flags is not yuio.POSITIONAL: 

1101 flags = [prefix + flag.lstrip("-") for flag in field.flags] 

1102 else: 

1103 flags = field.flags 

1104 

1105 field_usage = field.usage 

1106 if field_usage is None: 

1107 field_usage = usage 

1108 

1109 field_show_if_inherited = field.show_if_inherited 

1110 if field_show_if_inherited is None: 

1111 field_show_if_inherited = show_if_inherited 

1112 

1113 if field.is_subconfig: 

1114 assert flags is not yuio.POSITIONAL 

1115 assert issubclass(field.ty, Config) 

1116 if field.help_group is None: 

1117 if field.help is yuio.DISABLED: 

1118 subgroup = yuio.cli.HelpGroup("", help=yuio.DISABLED) 

1119 elif field.help: 

1120 lines = field.help.split("\n\n", 1) 

1121 title = lines[0].replace("\n", " ").rstrip(".") or name 

1122 help = textwrap.dedent(lines[1]) if len(lines) > 1 else "" 

1123 subgroup = yuio.cli.HelpGroup(title=title, help=help) 

1124 else: 

1125 subgroup = help_group 

1126 else: 

1127 subgroup = field.help_group 

1128 options.extend( 

1129 field.ty.__build_options( 

1130 flags[0], 

1131 dest + ".", 

1132 subgroup, 

1133 field_usage, 

1134 field_show_if_inherited, 

1135 ) 

1136 ) 

1137 continue 

1138 

1139 assert field.parser is not None 

1140 

1141 option_ctor = field.option_ctor or _default_option 

1142 option = option_ctor( 

1143 OptionSettings( 

1144 name=name, 

1145 parser=field.parser, 

1146 flags=flags, 

1147 required=field.required, 

1148 mutex_group=field.mutex_group, 

1149 usage=field_usage, 

1150 help=field.help, 

1151 help_group=field.help_group or help_group, 

1152 show_if_inherited=field_show_if_inherited, 

1153 merge=field.merge, 

1154 dest=dest, 

1155 default=field.default, 

1156 long_flag_prefix=prefix or "--", 

1157 ) 

1158 ) 

1159 options.append(option) 

1160 

1161 return options 

1162 

1163 @classmethod 

1164 def load_from_json_file( 

1165 cls, 

1166 path: str | pathlib.Path, 

1167 /, 

1168 *, 

1169 ignore_unknown_fields: bool = False, 

1170 ignore_missing_file: bool = False, 

1171 ) -> _t.Self: 

1172 """ 

1173 Load config from a ``.json`` file. 

1174 

1175 :param path: 

1176 path of the config file. 

1177 :param ignore_unknown_fields: 

1178 if :data:`True`, this method will ignore fields that aren't listed 

1179 in config class. 

1180 :param ignore_missing_file: 

1181 if :data:`True`, silently ignore a missing file error. This is useful 

1182 when loading a config from a home directory. 

1183 :returns: 

1184 a parsed config. 

1185 :raises: 

1186 :class:`~yuio.parse.ParsingError` if config parsing has failed 

1187 or if config file doesn't exist. 

1188 

1189 """ 

1190 

1191 return cls.__load_from_file( 

1192 path, json.loads, ignore_unknown_fields, ignore_missing_file 

1193 ) 

1194 

1195 @classmethod 

1196 def load_from_yaml_file( 

1197 cls, 

1198 path: str | pathlib.Path, 

1199 /, 

1200 *, 

1201 ignore_unknown_fields: bool = False, 

1202 ignore_missing_file: bool = False, 

1203 ) -> _t.Self: 

1204 """ 

1205 Load config from a ``.yaml`` file. 

1206 

1207 This requires `PyYaml <https://pypi.org/project/PyYAML/>`__ package 

1208 to be installed. 

1209 

1210 :param path: 

1211 path of the config file. 

1212 :param ignore_unknown_fields: 

1213 if :data:`True`, this method will ignore fields that aren't listed 

1214 in config class. 

1215 :param ignore_missing_file: 

1216 if :data:`True`, silently ignore a missing file error. This is useful 

1217 when loading a config from a home directory. 

1218 :returns: 

1219 a parsed config. 

1220 :raises: 

1221 :class:`~yuio.parse.ParsingError` if config parsing has failed 

1222 or if config file doesn't exist. Can raise :class:`ImportError` 

1223 if ``PyYaml`` is not available. 

1224 

1225 """ 

1226 

1227 try: 

1228 import yaml 

1229 except ImportError: 

1230 raise ImportError("PyYaml is not available") 

1231 

1232 return cls.__load_from_file( 

1233 path, yaml.safe_load, ignore_unknown_fields, ignore_missing_file 

1234 ) 

1235 

1236 @classmethod 

1237 def load_from_toml_file( 

1238 cls, 

1239 path: str | pathlib.Path, 

1240 /, 

1241 *, 

1242 ignore_unknown_fields: bool = False, 

1243 ignore_missing_file: bool = False, 

1244 ) -> _t.Self: 

1245 """ 

1246 Load config from a ``.toml`` file. 

1247 

1248 This requires 

1249 `tomllib <https://docs.python.org/3/library/tomllib.html>`_ or 

1250 `toml <https://pypi.org/project/toml/>`_ package 

1251 to be installed. 

1252 

1253 :param path: 

1254 path of the config file. 

1255 :param ignore_unknown_fields: 

1256 if :data:`True`, this method will ignore fields that aren't listed 

1257 in config class. 

1258 :param ignore_missing_file: 

1259 if :data:`True`, silently ignore a missing file error. This is useful 

1260 when loading a config from a home directory. 

1261 :returns: 

1262 a parsed config. 

1263 :raises: 

1264 :class:`~yuio.parse.ParsingError` if config parsing has failed 

1265 or if config file doesn't exist. Can raise :class:`ImportError` 

1266 if ``toml`` is not available. 

1267 

1268 """ 

1269 

1270 try: 

1271 import toml 

1272 except ImportError: 

1273 try: 

1274 import tomllib as toml 

1275 except ImportError: 

1276 raise ImportError("toml is not available") 

1277 

1278 return cls.__load_from_file( 

1279 path, toml.loads, ignore_unknown_fields, ignore_missing_file 

1280 ) 

1281 

1282 @classmethod 

1283 def __load_from_file( 

1284 cls, 

1285 path: str | pathlib.Path, 

1286 file_parser: _t.Callable[[str], _t.Any], 

1287 ignore_unknown_fields: bool = False, 

1288 ignore_missing_file: bool = False, 

1289 ) -> _t.Self: 

1290 path = pathlib.Path(path) 

1291 

1292 if ignore_missing_file and (not path.exists() or not path.is_file()): 

1293 return cls() 

1294 

1295 try: 

1296 loaded = file_parser(path.read_text()) 

1297 except Exception as e: 

1298 raise yuio.parse.ParsingError( 

1299 "Invalid config <c path>%s</c>:\n%s", 

1300 path, 

1301 yuio.string.Indent(e), 

1302 ) from None 

1303 

1304 return cls.load_from_parsed_file( 

1305 loaded, ignore_unknown_fields=ignore_unknown_fields, path=path 

1306 ) 

1307 

1308 @classmethod 

1309 def load_from_parsed_file( 

1310 cls, 

1311 parsed: dict[str, object], 

1312 /, 

1313 *, 

1314 ignore_unknown_fields: bool = False, 

1315 path: str | pathlib.Path | None = None, 

1316 ) -> _t.Self: 

1317 """ 

1318 Load config from parsed config file. 

1319 

1320 This method takes a dict with arbitrary values that you'd get from 

1321 parsing type-rich configs such as ``yaml`` or ``json``. 

1322 

1323 For example:: 

1324 

1325 with open("conf.yaml") as file: 

1326 config = Config.load_from_parsed_file(yaml.load(file)) 

1327 

1328 :param parsed: 

1329 data from parsed file. 

1330 :param ignore_unknown_fields: 

1331 if :data:`True`, this method will ignore fields that aren't listed 

1332 in config class. 

1333 :param path: 

1334 path of the original file, used for error reporting. 

1335 :returns: 

1336 a parsed config. 

1337 :raises: 

1338 :class:`~yuio.parse.ParsingError`. 

1339 

1340 """ 

1341 

1342 try: 

1343 result = cls.__load_from_parsed_file( 

1344 yuio.parse.ConfigParsingContext(parsed), ignore_unknown_fields, "" 

1345 ) 

1346 result.validate_config() 

1347 return result 

1348 except yuio.parse.ParsingError as e: 

1349 if path is None: 

1350 raise 

1351 else: 

1352 raise yuio.parse.ParsingError( 

1353 "Invalid config <c path>%s</c>:\n%s", 

1354 path, 

1355 yuio.string.Indent(e), 

1356 ) from None 

1357 

1358 @classmethod 

1359 def __load_from_parsed_file( 

1360 cls, 

1361 ctx: yuio.parse.ConfigParsingContext, 

1362 ignore_unknown_fields: bool = False, 

1363 field_prefix: str = "", 

1364 ) -> _t.Self: 

1365 value = ctx.value 

1366 

1367 if not isinstance(value, dict): 

1368 raise yuio.parse.ParsingError.type_mismatch(value, dict, ctx=ctx) 

1369 

1370 fields = {} 

1371 

1372 if not ignore_unknown_fields: 

1373 for name in value: 

1374 if name not in cls.__get_fields() and name != "$schema": 

1375 raise yuio.parse.ParsingError( 

1376 "Unknown field `%s`", f"{field_prefix}{name}" 

1377 ) 

1378 

1379 for name, field in cls.__get_fields().items(): 

1380 if name in value: 

1381 if field.is_subconfig: 

1382 fields[name] = field.ty.__load_from_parsed_file( 

1383 ctx.descend(value[name], name), 

1384 ignore_unknown_fields, 

1385 field_prefix=name + ".", 

1386 ) 

1387 else: 

1388 assert field.parser is not None 

1389 fields[name] = field.parser.parse_config_with_ctx( 

1390 ctx.descend(value[name], name) 

1391 ) 

1392 

1393 return cls(**fields) 

1394 

1395 def __getattribute(self, item): 

1396 value = super().__getattribute__(item) 

1397 if value is yuio.MISSING: 

1398 raise AttributeError(f"{item} is not configured") 

1399 else: 

1400 return value 

1401 

1402 # A dirty hack to hide `__getattribute__` from type checkers. 

1403 locals()["__getattribute__"] = __getattribute 

1404 

1405 def __repr__(self): 

1406 field_reprs = ", ".join( 

1407 f"{name}={getattr(self, name, yuio.MISSING)!r}" 

1408 for name in self.__get_fields() 

1409 ) 

1410 return f"{self.__class__.__name__}({field_reprs})" 

1411 

1412 def __rich_repr__(self): 

1413 for name in self.__get_fields(): 

1414 yield name, getattr(self, name, yuio.MISSING) 

1415 

1416 def validate_config(self): 

1417 """ 

1418 Perform config validation. 

1419 

1420 This function is called every time a config is loaded from CLI arguments, 

1421 file, or environment variables. It should check that config is correct, 

1422 and raise :class:`yuio.parse.ParsingError` if it's not. 

1423 

1424 :raises: 

1425 :class:`~yuio.parse.ParsingError`. 

1426 

1427 """ 

1428 

1429 @classmethod 

1430 def to_json_schema( 

1431 cls, ctx: yuio.json_schema.JsonSchemaContext 

1432 ) -> yuio.json_schema.JsonSchemaType: 

1433 """ 

1434 Create a JSON schema object based on this config. 

1435 

1436 The purpose of this method is to make schemas for use in IDEs, i.e. to provide 

1437 autocompletion or simple error checking. The returned schema is not guaranteed 

1438 to reflect all constraints added to the parser. 

1439 

1440 :param ctx: 

1441 context for building a schema. 

1442 :returns: 

1443 a JSON schema that describes structure of this config. 

1444 

1445 """ 

1446 

1447 return ctx.add_type(cls, _tx.type_repr(cls), lambda: cls.__to_json_schema(ctx)) 

1448 

1449 def to_json_value( 

1450 self, *, include_defaults: bool = True 

1451 ) -> yuio.json_schema.JsonValue: 

1452 """ 

1453 Convert this config to a representation suitable for JSON serialization. 

1454 

1455 :param include_defaults: 

1456 if :data:`False`, default values will be skipped. 

1457 :returns: 

1458 a config converted to JSON-serializable representation. 

1459 :raises: 

1460 :class:`TypeError` if any of the config fields contain values that can't 

1461 be converted to JSON by their respective parsers. 

1462 

1463 """ 

1464 

1465 data = {} 

1466 for name, field in self.__get_fields().items(): 

1467 if not include_defaults and name not in self.__dict__: 

1468 continue 

1469 if field.is_subconfig: 

1470 value = getattr(self, name).to_json_value( 

1471 include_defaults=include_defaults 

1472 ) 

1473 if value: 

1474 data[name] = value 

1475 else: 

1476 assert field.parser 

1477 try: 

1478 value = getattr(self, name) 

1479 except AttributeError: 

1480 pass 

1481 else: 

1482 data[name] = field.parser.to_json_value(value) 

1483 return data 

1484 

1485 @classmethod 

1486 def __to_json_schema( 

1487 cls, ctx: yuio.json_schema.JsonSchemaContext 

1488 ) -> yuio.json_schema.JsonSchemaType: 

1489 properties: dict[str, yuio.json_schema.JsonSchemaType] = {} 

1490 defaults = {} 

1491 

1492 properties["$schema"] = yuio.json_schema.String() 

1493 

1494 for name, field in cls.__get_fields().items(): 

1495 if field.is_subconfig: 

1496 properties[name] = field.ty.to_json_schema(ctx) 

1497 else: 

1498 assert field.parser 

1499 field_schema = field.parser.to_json_schema(ctx) 

1500 if field.help and field.help is not yuio.DISABLED: 

1501 field_schema = yuio.json_schema.Meta( 

1502 field_schema, description=field.help 

1503 ) 

1504 properties[name] = field_schema 

1505 if field.default is not yuio.MISSING: 

1506 try: 

1507 defaults[name] = field.parser.to_json_value(field.default) 

1508 except TypeError: 

1509 pass 

1510 

1511 return yuio.json_schema.Meta( 

1512 yuio.json_schema.Object(properties), 

1513 title=cls.__name__, 

1514 description=cls.__doc__, 

1515 default=defaults, 

1516 ) 

1517 

1518 

1519Config.__init_subclass__(_allow_positionals=False) 

1520 

1521 

1522@dataclass(eq=False, kw_only=True) 

1523class OptionSettings: 

1524 """ 

1525 Settings for creating an :class:`~yuio.cli.Option` derived from field's type 

1526 and configuration. 

1527 

1528 """ 

1529 

1530 name: str | None 

1531 """ 

1532 Name of config field or app parameter that caused creation of this option. 

1533 

1534 """ 

1535 

1536 parser: yuio.parse.Parser[_t.Any] 

1537 """ 

1538 Parser associated with this option. 

1539 

1540 """ 

1541 

1542 flags: list[str] | yuio.Positional 

1543 """ 

1544 See :attr:`yuio.cli.Option.flags`. 

1545 

1546 """ 

1547 

1548 required: bool 

1549 """ 

1550 See :attr:`yuio.cli.Option.required`. 

1551 

1552 """ 

1553 

1554 mutex_group: None | MutuallyExclusiveGroup 

1555 """ 

1556 See :attr:`yuio.cli.Option.mutex_group`. 

1557 

1558 """ 

1559 

1560 usage: yuio.Group | bool 

1561 """ 

1562 See :attr:`yuio.cli.Option.usage`. 

1563 

1564 """ 

1565 

1566 help: str | yuio.Disabled 

1567 """ 

1568 See :attr:`yuio.cli.Option.help`. 

1569 

1570 """ 

1571 

1572 help_group: HelpGroup | None 

1573 """ 

1574 See :attr:`yuio.cli.Option.help_group`. 

1575 

1576 """ 

1577 

1578 show_if_inherited: bool 

1579 """ 

1580 See :attr:`yuio.cli.Option.show_if_inherited`. 

1581 

1582 """ 

1583 

1584 merge: _t.Callable[[_t.Any, _t.Any], _t.Any] | None 

1585 """ 

1586 See :attr:`yuio.cli.ValueOption.merge`. 

1587 

1588 """ 

1589 

1590 dest: str 

1591 """ 

1592 See :attr:`yuio.cli.ValueOption.dest`. We don't provide any guarantees about `dest`\\ 's 

1593 contents and recommend treating it as an opaque value. 

1594 

1595 """ 

1596 

1597 default: _t.Any | yuio.Missing 

1598 """ 

1599 See :attr:`yuio.cli.ValueOption.default`. 

1600 

1601 """ 

1602 

1603 long_flag_prefix: str 

1604 """ 

1605 This argument will contain prefix that was added to all :attr:`~OptionSettings.flags`. 

1606 For apps and top level configs if will be ``"--"``, for nested configs it will 

1607 include additional prefixes, for example ``"--nested-"``. 

1608 

1609 """ 

1610 

1611 

1612OptionCtor: _t.TypeAlias = _t.Callable[[OptionSettings], yuio.cli.Option[T]] 

1613 

1614 

1615def _default_option(s: OptionSettings): 

1616 if s.flags is not yuio.POSITIONAL and yuio.parse._is_bool_parser(s.parser): 

1617 return bool_option()(s) 

1618 elif s.parser.supports_parse_many(): 

1619 return parse_many_option()(s) 

1620 else: 

1621 return parse_one_option()(s) 

1622 

1623 

1624def bool_option(*, neg_flags: list[str] | None = None) -> OptionCtor[bool]: 

1625 """ 

1626 Factory for :class:`yuio.cli.BoolOption`. 

1627 

1628 :param neg_flags: 

1629 additional set of flags that will set option's value to :data:`False`. If not 

1630 given, a negative flag will be created by adding prefix ``no-`` to the first 

1631 long flag of the option. 

1632 :example: 

1633 Boolean flag :flag:`--json` implicitly creates flag :flag:`--no-json`: 

1634 

1635 .. code-block:: python 

1636 :emphasize-lines: 5 

1637 

1638 @yuio.app.app 

1639 def main( 

1640 json: bool = yuio.app.field( 

1641 default=False, 

1642 option_ctor=yuio.app.bool_option(), 

1643 ), 

1644 ): ... 

1645 

1646 Boolean flag :flag:`--json` with explicitly provided flag 

1647 :flag:`--disable-json`: 

1648 

1649 .. code-block:: python 

1650 :emphasize-lines: 5-7 

1651 

1652 @yuio.app.app 

1653 def main( 

1654 json: bool = yuio.app.field( 

1655 default=False, 

1656 option_ctor=yuio.app.bool_option( 

1657 neg_flags=["--disable-json"], 

1658 ), 

1659 ), 

1660 ): ... 

1661 

1662 """ 

1663 

1664 def ctor(s: OptionSettings, /): 

1665 if s.flags is yuio.POSITIONAL: 

1666 raise TypeError(f"error in {s.name}: BoolOption can't be positional") 

1667 if neg_flags is None: 

1668 _neg_flags = [] 

1669 for flag in s.flags: 

1670 if not yuio.cli._is_short(flag) and flag.startswith(s.long_flag_prefix): 

1671 prefix = s.long_flag_prefix.strip("-") 

1672 if prefix: 

1673 prefix += "-" 

1674 suffix = flag[len(s.long_flag_prefix) :].removeprefix("-") 

1675 _neg_flags.append(f"--{prefix}no-{suffix}") 

1676 break 

1677 elif s.long_flag_prefix == "--": 

1678 _neg_flags = neg_flags 

1679 else: 

1680 _neg_flags = [] 

1681 for flag in neg_flags: 

1682 _neg_flags.append(s.long_flag_prefix + flag.lstrip("-")) 

1683 return yuio.cli.BoolOption( 

1684 pos_flags=s.flags, 

1685 neg_flags=_neg_flags, 

1686 required=s.required, 

1687 mutex_group=s.mutex_group, 

1688 usage=s.usage, 

1689 help=s.help, 

1690 help_group=s.help_group, 

1691 show_if_inherited=s.show_if_inherited, 

1692 dest=s.dest, 

1693 parser=s.parser, 

1694 merge=s.merge, 

1695 default=s.default, 

1696 ) 

1697 

1698 return ctor 

1699 

1700 

1701def parse_one_option() -> OptionCtor[_t.Any]: 

1702 """ 

1703 Factory for :class:`yuio.cli.ParseOneOption`. 

1704 

1705 This option takes one argument and passes it 

1706 to :meth:`Parser.parse() <yuio.parse.Parser.parse>`. 

1707 

1708 :example: 

1709 Forcing a field which can use :func:`parse_many_option` 

1710 to use :func:`parse_one_option` instead. 

1711 

1712 .. code-block:: python 

1713 :emphasize-lines: 6 

1714 

1715 @yuio.app.app 

1716 def main( 

1717 files: list[str] = yuio.app.field( 

1718 default=[], 

1719 parser=yuio.parse.List(yuio.parse.Int(), delimiter=","), 

1720 option_ctor=yuio.app.parse_one_option(), 

1721 ), 

1722 ): ... 

1723 

1724 This will disable multi-argument syntax: 

1725 

1726 .. code-block:: console 

1727 

1728 $ prog --files a.txt,b.txt # Ok 

1729 $ prog --files a.txt b.txt # Error: `--files` takes one argument. 

1730 

1731 """ 

1732 

1733 def ctor(s: OptionSettings, /): 

1734 return yuio.cli.ParseOneOption( 

1735 flags=s.flags, 

1736 required=s.required, 

1737 mutex_group=s.mutex_group, 

1738 usage=s.usage, 

1739 help=s.help, 

1740 help_group=s.help_group, 

1741 show_if_inherited=s.show_if_inherited, 

1742 dest=s.dest, 

1743 parser=s.parser, 

1744 merge=s.merge, 

1745 default=s.default, 

1746 ) 

1747 

1748 return ctor 

1749 

1750 

1751def parse_many_option() -> OptionCtor[_t.Any]: 

1752 """ 

1753 Factory for :class:`yuio.cli.ParseManyOption`. 

1754 

1755 This option takes multiple arguments and passes them 

1756 to :meth:`Parser.parse_many() <yuio.parse.Parser.parse_many>`. 

1757 

1758 """ 

1759 

1760 def ctor(s: OptionSettings, /): 

1761 return yuio.cli.ParseManyOption( 

1762 flags=s.flags, 

1763 required=s.required, 

1764 mutex_group=s.mutex_group, 

1765 usage=s.usage, 

1766 help=s.help, 

1767 help_group=s.help_group, 

1768 show_if_inherited=s.show_if_inherited, 

1769 dest=s.dest, 

1770 parser=s.parser, 

1771 merge=s.merge, 

1772 default=s.default, 

1773 ) 

1774 

1775 return ctor 

1776 

1777 

1778def store_const_option(const: T) -> OptionCtor[T]: 

1779 """ 

1780 Factory for :class:`yuio.cli.StoreConstOption`. 

1781 

1782 This options takes no arguments. When it's encountered amongst CLI arguments, 

1783 it writes `const` to the resulting config. 

1784 

1785 """ 

1786 

1787 def ctor(s: OptionSettings, /): 

1788 if s.flags is yuio.POSITIONAL: 

1789 raise TypeError(f"error in {s.name}: StoreConstOption can't be positional") 

1790 

1791 return yuio.cli.StoreConstOption( 

1792 flags=s.flags, 

1793 required=s.required, 

1794 mutex_group=s.mutex_group, 

1795 usage=s.usage, 

1796 help=s.help, 

1797 help_group=s.help_group, 

1798 show_if_inherited=s.show_if_inherited, 

1799 dest=s.dest, 

1800 merge=s.merge, 

1801 default=s.default, 

1802 const=const, 

1803 ) 

1804 

1805 return ctor 

1806 

1807 

1808def count_option() -> OptionCtor[int]: 

1809 """ 

1810 Factory for :class:`yuio.cli.CountOption`. 

1811 

1812 This option counts number of times it's encountered amongst CLI arguments. 

1813 

1814 Equivalent to using :func:`store_const_option` with ``const=1`` 

1815 and ``merge=lambda a, b: a + b``. 

1816 

1817 :example: 

1818 

1819 .. code-block:: python 

1820 

1821 @yuio.app.app 

1822 def main( 

1823 quiet: int = yuio.app.field( 

1824 default=0, 

1825 flags=["-q", "--quiet"], 

1826 option_ctor=yuio.app.count_option(), 

1827 ), 

1828 ): ... 

1829 

1830 .. code-block:: console 

1831 

1832 prog -qq # quiet=2 

1833 

1834 """ 

1835 

1836 def ctor(s: OptionSettings, /): 

1837 if s.flags is yuio.POSITIONAL: 

1838 raise TypeError(f"error in {s.name}: CountOption can't be positional") 

1839 

1840 return yuio.cli.CountOption( 

1841 flags=s.flags, 

1842 required=s.required, 

1843 mutex_group=s.mutex_group, 

1844 usage=s.usage, 

1845 help=s.help, 

1846 help_group=s.help_group, 

1847 show_if_inherited=s.show_if_inherited, 

1848 dest=s.dest, 

1849 default=s.default, 

1850 ) 

1851 

1852 return ctor 

1853 

1854 

1855def store_true_option() -> OptionCtor[bool]: 

1856 """ 

1857 Factory for :class:`yuio.cli.StoreTrueOption`. 

1858 

1859 Equivalent to using :func:`store_const_option` with ``const=True``. 

1860 

1861 """ 

1862 

1863 def ctor(s: OptionSettings, /): 

1864 if s.flags is yuio.POSITIONAL: 

1865 raise TypeError(f"error in {s.name}: StoreTrueOption can't be positional") 

1866 

1867 return yuio.cli.StoreTrueOption( 

1868 flags=s.flags, 

1869 required=s.required, 

1870 mutex_group=s.mutex_group, 

1871 usage=s.usage, 

1872 help=s.help, 

1873 help_group=s.help_group, 

1874 show_if_inherited=s.show_if_inherited, 

1875 dest=s.dest, 

1876 default=s.default, 

1877 ) 

1878 

1879 return ctor 

1880 

1881 

1882def store_false_option() -> OptionCtor[bool]: 

1883 """ 

1884 Factory for :class:`yuio.cli.StoreFalseOption`. 

1885 

1886 Equivalent to using :func:`store_const_option` with ``const=False``. 

1887 

1888 """ 

1889 

1890 def ctor(s: OptionSettings, /): 

1891 if s.flags is yuio.POSITIONAL: 

1892 raise TypeError(f"error in {s.name}: StoreFalseOption can't be positional") 

1893 

1894 return yuio.cli.StoreFalseOption( 

1895 flags=s.flags, 

1896 required=s.required, 

1897 mutex_group=s.mutex_group, 

1898 usage=s.usage, 

1899 help=s.help, 

1900 help_group=s.help_group, 

1901 show_if_inherited=s.show_if_inherited, 

1902 dest=s.dest, 

1903 default=s.default, 

1904 ) 

1905 

1906 return ctor