1# Copyright 2022 The Chromium Authors 2# Use of this source code is governed by a BSD-style license that can be 3# found in the LICENSE file. 4 5from __future__ import annotations 6 7import abc 8import argparse 9import collections 10import collections.abc 11import enum 12import inspect 13import logging 14import textwrap 15from typing import (TYPE_CHECKING, Any, Callable, Dict, Final, Generic, 16 Iterable, List, Optional, Set, Tuple, Type, TypeVar, Union, 17 cast) 18from urllib.parse import urlparse 19 20import tabulate 21 22# Use indirection to support pyfakefs 23from crossbench import compat, exception, helper 24from crossbench import path as pth 25from crossbench.helper import ChangeCWD 26from crossbench.parse import ObjectParser, PathParser 27 28if TYPE_CHECKING: 29 ArgParserType = Union[Callable[..., Any], Type] 30 31 32class ConfigError(argparse.ArgumentTypeError): 33 pass 34 35 36NOT_SET: Final[object] = object() 37 38 39class _ConfigArgParser: 40 """ 41 Parser for a single config arg. 42 """ 43 44 def __init__( # pylint: disable=redefined-builtin 45 self, 46 parser: ConfigParser, 47 name: str, 48 type: Optional[ArgParserType], 49 default: Any = NOT_SET, 50 choices: Optional[frozenset[Any]] = None, 51 aliases: Iterable[str] = tuple(), 52 help: Optional[str] = None, 53 is_list: bool = False, 54 required: bool = False, 55 depends_on: Optional[Iterable[str]] = None): 56 self.parser: ConfigParser = parser 57 self.name: str = name 58 self.aliases = tuple(aliases) 59 self._validate_aliases() 60 self.type: Optional[ArgParserType] = type 61 self.required: bool = required 62 self.help: Optional[str] = help 63 self.is_list: bool = is_list 64 type_is_class = inspect.isclass(type) 65 self.type_is_class: bool = type_is_class 66 self.is_enum: bool = type_is_class and issubclass(type, enum.Enum) 67 self.config_object_type: Optional[Type[ConfigObject]] = None 68 if type_is_class and issubclass(type, ConfigObject): 69 self.config_object_type = type 70 self.depends_on = frozenset(depends_on) if depends_on else frozenset() 71 self.choices: Optional[frozenset] = self._validate_choices(choices) 72 if self.type: 73 self._validate_callable() 74 self.default = self._validate_default(default) 75 self._validate_depends_on(depends_on) 76 77 def _validate_callable(self) -> None: 78 assert self.type, "Expected not-None type" 79 if not callable(self.type): 80 raise TypeError( 81 f"Expected type to be a class or a callable, but got: {self.type}") 82 if self.config_object_type: 83 # Config objects and depends_on are handled specially. 84 return 85 86 signature = None 87 if getattr(self.type, "__module__") != "builtins": 88 try: 89 signature = inspect.signature(self.type) 90 except ValueError as e: 91 logging.debug("Could not get signature for %s: %s", self.type, e) 92 93 if not signature: 94 if not self.depends_on: 95 return 96 raise TypeError( 97 f"Type for config '{self.name}' should take at least 2 arguments " 98 f"to support depends_on, but got builtin: {self.type}") 99 100 if len(signature.parameters) == 0: 101 raise TypeError( 102 f"Type for config '{self.name}' should take at least 1 argument, " 103 f"but got: {self.type}") 104 if self.depends_on and len(signature.parameters) <= 1: 105 raise TypeError( 106 f"Type for config '{self.name}' should take at least 2 arguments " 107 f"to support depends_on, but got: {self.type}") 108 109 def _validate_aliases(self) -> None: 110 unique = set(self.aliases) 111 if self.name in unique: 112 raise ValueError(f"Config name '{self.name}' cannot be part " 113 f"of the aliases='{self.aliases}'") 114 ObjectParser.unique_sequence(self.aliases, "aliases", ValueError) 115 116 def _validate_choices( 117 self, choices: Optional[frozenset[Any]]) -> Optional[frozenset]: 118 if self.is_enum: 119 return self._validate_enum_choices(choices) 120 if choices is None: 121 return None 122 choices_list = list(choices) 123 assert choices_list, f"Got empty choices: {choices}" 124 frozen_choices = frozenset(choices_list) 125 if len(frozen_choices) != len(choices_list): 126 raise ValueError("Choices must be unique, but got: {choices}") 127 return frozen_choices 128 129 def _validate_enum_choices( 130 self, choices: Optional[frozenset[Any]]) -> Optional[frozenset]: 131 assert self.is_enum 132 assert self.type 133 enum_type: Type[enum.Enum] = cast(Type[enum.Enum], self.type) 134 if choices is None: 135 return frozenset(enum for enum in enum_type) 136 for choice in choices: 137 assert isinstance( 138 choice, 139 enum_type), (f"Enum choices must be {enum_type}, but got: {choice}") 140 return frozenset(choices) 141 142 def _validate_default(self, default: Any) -> Any: 143 if default is NOT_SET: 144 return None 145 if default is None and self.required: 146 raise ValueError( 147 f"ConfigArg name={self.name}: use required=False without " 148 "a 'default' argument when default is None") 149 if self.required: 150 raise ValueError("Required argument should have an empty default value, " 151 f"but got default={repr(default)}") 152 if self.is_enum: 153 return self._validate_enum_default(default) 154 # TODO: Remove once pytype can handle self.type 155 maybe_class: Optional[ArgParserType] = self.type 156 if self.is_list: 157 self._validate_list_default(default, maybe_class) 158 elif maybe_class and inspect.isclass(maybe_class): 159 self._validate_class_default(default, maybe_class) 160 return default 161 162 def _validate_class_default(self, default: Any, class_type: Type) -> None: 163 if not isinstance(default, class_type): 164 raise ValueError(f"Expected default value of type={class_type.__name__}, " 165 f"but got type={type(default).__name__}: {default}") 166 167 def _validate_list_default(self, default: Any, 168 maybe_class: Optional[ArgParserType]) -> None: 169 if not isinstance(default, collections.abc.Sequence): 170 raise ValueError(f"List default must be a sequence, but got: {default}") 171 if isinstance(default, str): 172 raise ValueError( 173 f"List default should not be a string, but got: {repr(default)}") 174 if inspect.isclass(maybe_class): 175 for default_item in default: 176 if not isinstance(default_item, maybe_class): 177 raise ValueError( 178 f"Expected default list item of type={self.type}, " 179 f"but got type={type(default_item).__name__}: {default_item}") 180 181 def _validate_enum_default(self, default: Any) -> None: 182 enum_type: Type[enum.Enum] = cast(Type[enum.Enum], self.type) 183 if self.is_list: 184 default_list = default 185 else: 186 default_list = (default,) 187 for default_item in default_list: 188 assert isinstance(default_item, enum_type), ( 189 f"Default must be a {enum_type} enum, but got: {default}") 190 return default 191 192 def _validate_depends_on(self, depends_on: Optional[Iterable[str]]) -> None: 193 if not depends_on: 194 return 195 if not self._is_iterable_non_str(depends_on): 196 raise TypeError(f"Expected depends_on to be a collection of str, " 197 f"but got {type(depends_on).__name__}: " 198 f"{repr(depends_on)}") 199 for i, value in enumerate(depends_on): 200 if not isinstance(value, str): 201 raise TypeError(f"Expected depends_on[{i}] to be a str, but got " 202 f"{type(value).__name__}: {repr(value)}") 203 if not self.type: 204 raise ValueError(f"Argument '{self.name}' without a type " 205 "cannot have argument dependencies.") 206 if self.is_enum: 207 raise ValueError(f"Enum '{self.name}' cannot have argument dependencies") 208 209 def _is_iterable_non_str(self, value: Any) -> bool: 210 if isinstance(value, str): 211 return False 212 return isinstance(value, collections.abc.Iterable) 213 214 @property 215 def cls(self) -> Type: 216 return self.parser.cls 217 218 @property 219 def cls_name(self) -> str: 220 return self.cls.__name__ 221 222 @property 223 def help_text(self) -> str: 224 items: List[Tuple[str, str]] = [] 225 if self.type is None: 226 if self.is_list: 227 items.append(("type", "list")) 228 else: 229 if self.is_list: 230 items.append(("type", f"List[{self.type.__qualname__}]")) 231 else: 232 items.append(("type", str(self.type.__qualname__))) 233 234 if self.required: 235 items.append(("required", "")) 236 elif self.default is None: 237 items.append(("default", "not set")) 238 else: 239 if self.is_list: 240 if not self.default: 241 items.append(("default", "[]")) 242 else: 243 items.append(("default", ",".join(map(str, self.default)))) 244 else: 245 items.append(("default", str(self.default))) 246 if self.is_enum: 247 items.extend(self._enum_help_text()) 248 elif self.choices: 249 items.append(self._choices_help_text(self.choices)) 250 251 text = tabulate.tabulate(items, tablefmt="presto") 252 if self.help: 253 return f"{self.help}\n{text}" 254 return text 255 256 def _choices_help_text(self, choices: Iterable) -> Tuple[str, str]: 257 return ("choices", ", ".join(map(str, choices))) 258 259 def _enum_help_text(self) -> List[Tuple[str, str]]: 260 if self.type and hasattr(self.type, "help_text_items"): 261 # See compat.StrEnumWithHelp 262 return [("choices", ""), *self.type.help_text_items()] 263 assert self.choices 264 return [self._choices_help_text(choice.value for choice in self.choices)] 265 266 def parse(self, config_data: Dict[str, Any], 267 depending_kwargs: Dict[str, Any]) -> Any: 268 data = None 269 if self.name in config_data: 270 data = config_data.pop(self.name) 271 elif self.aliases: 272 data = self._pop_alias(config_data) 273 274 if data is None: 275 if self.required and self.default is None: 276 raise ValueError( 277 f"{self.cls_name}: " 278 f"No value provided for required config option '{self.name}'") 279 data = self.default 280 if depending_kwargs: 281 self._validate_depending_kwargs(depending_kwargs) 282 else: 283 self._validate_depending_kwargs(depending_kwargs) 284 self._validate_no_aliases(config_data) 285 if data is None and not depending_kwargs: 286 return None 287 if self.is_list: 288 return self.parse_list_data(data, depending_kwargs) 289 return self.parse_data(data, depending_kwargs) 290 291 def _pop_alias(self, config_data) -> Optional[Any]: 292 value: Optional[Any] = None 293 found: bool = False 294 for alias in self.aliases: 295 if alias not in config_data: 296 continue 297 if found: 298 raise ValueError(f"Ambiguous arguments, got alias for {self.name} " 299 "specified more than once.") 300 value = config_data.pop(alias, None) 301 found = True 302 return value 303 304 def _validate_depending_kwargs(self, depending_kwargs: Dict[str, 305 Any]) -> None: 306 if not self.depends_on and depending_kwargs: 307 raise ValueError(f"{self.name} has no depending arguments, " 308 f"but got: {depending_kwargs}") 309 for arg_name in self.depends_on: 310 if arg_name not in depending_kwargs: 311 raise ValueError( 312 f"{arg_name}.depends_on['{arg_name}'] was not provided.") 313 314 def _validate_no_aliases(self, config_data) -> None: 315 for alias in self.aliases: 316 if alias in config_data: 317 raise ValueError( 318 f"{self.cls_name}: ", 319 f"Got conflicting argument, '{self.name}' and '{alias}' " 320 "cannot be specified together.") 321 322 def _validate_type_without_depending_kwargs( 323 self, depending_kwargs: Dict[str, Any]) -> None: 324 if depending_kwargs: 325 raise ValueError( 326 f"{str(self.type)} does not accept " 327 f"additional depending arguments, but got: {depending_kwargs}") 328 329 def parse_list_data(self, data: Any, 330 depending_kwargs: Dict[str, Any]) -> List[Any]: 331 if isinstance(data, str): 332 data = data.split(",") 333 if not isinstance(data, (list, tuple)): 334 raise ValueError(f"{self.cls_name}.{self.name}: " 335 f"Expected sequence got {type(data).__name__}") 336 return [self.parse_data(value, depending_kwargs) for value in data] 337 338 def parse_data(self, data: Any, depending_kwargs: Dict[str, Any]) -> Any: 339 if self.is_enum: 340 self._validate_type_without_depending_kwargs(depending_kwargs) 341 return self.parse_enum_data(data) 342 if self.choices and data not in self.choices: 343 raise ValueError(f"{self.cls_name}.{self.name}: " 344 f"Invalid choice '{data}', choices are {self.choices}") 345 if self.type is None: 346 self._validate_type_without_depending_kwargs(depending_kwargs) 347 return data 348 if self.type is bool: 349 self._validate_type_without_depending_kwargs(depending_kwargs) 350 if not isinstance(data, bool): 351 raise ValueError( 352 f"{self.cls_name}.{self.name}: Expected bool, but got {data}") 353 elif self.type in (float, int): 354 self._validate_type_without_depending_kwargs(depending_kwargs) 355 if not isinstance(data, (float, int)): 356 raise ValueError( 357 f"{self.cls_name}.{self.name}: Expected number, got {data}") 358 if self.config_object_type: 359 # TODO: support custom depending kwargs with ConfigObject 360 self._validate_type_without_depending_kwargs(depending_kwargs) 361 return self.parse_config_object(data) 362 return self.type(data, **depending_kwargs) 363 364 def parse_config_object(self, data) -> Any: 365 config_object: ConfigObject = self.config_object_type.parse(data) 366 return config_object.to_argument_value() 367 368 def parse_enum_data(self, data: Any) -> enum.Enum: 369 assert self.is_enum 370 assert self.choices 371 assert self.type 372 assert isinstance(self.type, type), "type for enum has to be a Class." 373 if issubclass(self.type, ConfigEnum): 374 return self.type.parse(data) 375 assert issubclass(self.type, enum.Enum) 376 return ObjectParser.enum(self.name, self.type, data, self.choices) 377 378 379 380 381ConfigEnumT = TypeVar("ConfigEnumT", bound="ConfigEnum") 382 383 384class ConfigEnum(compat.StrEnumWithHelp): 385 386 @classmethod 387 def parse(cls: Type[ConfigEnumT], value: Any) -> ConfigEnumT: 388 return ObjectParser.enum(cls.__name__, cls, value, cls) 389 390 391ConfigObjectT = TypeVar("ConfigObjectT", bound="ConfigObject") 392 393class ConfigObject(abc.ABC): 394 """A ConfigObject is a placeholder object with parsed values from 395 a ConfigParser. 396 - It is used to do complex input validation when the final instantiated 397 objects contain other nested config-parsed objects, 398 - It is then used to create a real instance of an object. 399 """ 400 VALID_EXTENSIONS: Tuple[str, ...] = (".hjson", ".json") 401 402 @classmethod 403 def value_has_path_prefix(cls, value: str) -> bool: 404 return PathParser.PATH_PREFIX.match(value) is not None 405 406 def __post_init__(self) -> None: 407 self.validate() 408 409 def validate(self) -> None: 410 """Override to perform validation of config properties that cannot be 411 checked individually (aka depend on each other). 412 """ 413 414 def to_argument_value(self) -> Any: 415 """ Called to convert a ConfigObject to the value stored in ConfigParser 416 result. """ 417 return self 418 419 @classmethod 420 def parse(cls: Type[ConfigObjectT], value: Any, **kwargs) -> ConfigObjectT: 421 # Quick return for default values used by parsers. 422 if isinstance(value, cls): 423 return value 424 # Make sure we wrap any exception in a argparse.ArgumentTypeError) 425 with exception.annotate_argparsing(f"Parsing {cls.__name__}"): 426 return cls._parse(value, **kwargs) 427 raise exception.UnreachableError() 428 429 @classmethod 430 def _parse(cls: Type[ConfigObjectT], value: Any, **kwargs) -> ConfigObjectT: 431 if isinstance(value, dict): 432 return cls.parse_dict(value, **kwargs) 433 if not value: 434 raise ConfigError(f"{cls.__name__}: Empty config value") 435 if isinstance(value, pth.LocalPath): 436 return cls.parse_path(value, **kwargs) 437 if isinstance(value, str): 438 if urlparse(value).scheme: 439 # TODO(346197734): use parse_url here 440 return cls.parse_str(value, **kwargs) 441 try: 442 maybe_path = pth.LocalPath(value).expanduser() 443 if cls.is_valid_path(maybe_path): 444 return cls.parse_path(maybe_path, **kwargs) 445 if cls.value_has_path_prefix(value): 446 return cls.parse_unknown_path(maybe_path, **kwargs) 447 except OSError: 448 pass 449 return cls.parse_str(value, **kwargs) 450 return cls.parse_other(value, **kwargs) 451 452 @classmethod 453 def parse_other(cls: Type[ConfigObjectT], value: Any) -> ConfigObjectT: 454 raise ConfigError( 455 f"Invalid config input type {type(value).__name__}: {value}") 456 457 @classmethod 458 @abc.abstractmethod 459 def parse_str(cls: Type[ConfigObjectT], value: str) -> ConfigObjectT: 460 """Custom implementation for parsing config values that are 461 not handled by the default .parse(...) method.""" 462 raise NotImplementedError() 463 464 @classmethod 465 def is_valid_path(cls, path: pth.LocalPath) -> bool: 466 if not path.is_file(): 467 return False 468 return path.suffix in cls.VALID_EXTENSIONS 469 470 @classmethod 471 def parse_unknown_path(cls: Type[ConfigObjectT], path: pth.LocalPath, 472 **kwargs) -> ConfigObjectT: 473 # TODO: this should be redirected to parse_config_path 474 return cls.parse_str(str(path), **kwargs) 475 476 @classmethod 477 def parse_path(cls: Type[ConfigObjectT], path: pth.LocalPath, 478 **kwargs) -> ConfigObjectT: 479 return cls.parse_config_path(path, **kwargs) 480 481 @classmethod 482 def parse_inline_hjson(cls: Type[ConfigObjectT], value: str, 483 **kwargs) -> ConfigObjectT: 484 with exception.annotate(f"Parsing inline {cls.__name__}"): 485 data = ObjectParser.inline_hjson(value) 486 return cls.parse_dict(data, **kwargs) 487 raise exception.UnreachableError() 488 489 @classmethod 490 def parse_config_path(cls: Type[ConfigObjectT], path: pth.LocalPathLike, 491 **kwargs) -> ConfigObjectT: 492 with exception.annotate_argparsing(f"Parsing {cls.__name__} file: {path}"): 493 file_path = PathParser.existing_file_path(path) 494 data = ObjectParser.dict_hjson_file(file_path) 495 with ChangeCWD(file_path.parent): 496 return cls.parse_dict(data, **kwargs) 497 raise exception.UnreachableError() 498 499 @classmethod 500 @abc.abstractmethod 501 def parse_dict(cls: Type[ConfigObjectT], config: Dict[str, 502 Any]) -> ConfigObjectT: 503 raise NotImplementedError() 504 505 506class _ConfigKwargsParser: 507 508 def __init__(self, parser: ConfigParser, config_data: Dict[str, Any]): 509 self._parser = parser 510 self._kwargs: Dict[str, Any] = {} 511 self._processed_args: Set[str] = set() 512 self._config_data = config_data 513 self._parse() 514 515 def _parse(self) -> None: 516 for arg_parser in self._parser.arg_parsers: 517 if arg_parser.name in self._processed_args: 518 # Already previously handled by some depending_on argument. 519 continue 520 self._parse_arg(arg_parser) 521 522 def _parse_arg(self, arg_parser: _ConfigArgParser) -> None: 523 arg_name: str = arg_parser.name 524 if arg_name in self._processed_args: 525 raise ValueError( 526 f"Recursive argument dependency on '{arg_name}' cannot be resolved.") 527 self._processed_args.add(arg_name) 528 with exception.annotate(f"Parsing ...['{arg_name}']:"): 529 depending_kwargs = self._maybe_parse_depending_args(arg_parser) 530 self._kwargs[arg_name] = arg_parser.parse(self._config_data, 531 depending_kwargs) 532 533 def _maybe_parse_depending_args( 534 self, arg_parser: _ConfigArgParser) -> Dict[str, Any]: 535 depending_args: Dict[str, Any] = {} 536 if not arg_parser.depends_on: 537 return depending_args 538 with exception.annotate(f"Parsing ...['{arg_parser.name}'].depends_on:"): 539 for depending_arg_name in arg_parser.depends_on: 540 depending_args[depending_arg_name] = self._parse_depending_arg( 541 depending_arg_name) 542 return depending_args 543 544 def _parse_depending_arg(self, arg_name: str) -> Any: 545 if arg_name in self._kwargs: 546 return self._kwargs[arg_name] 547 with exception.annotate(f"Parsing ...['{arg_name}']:"): 548 self._parse_arg(self._parser.get_argument(arg_name)) 549 assert arg_name in self._kwargs, ( 550 f"Failure when parsing depending {arg_name}") 551 return self._kwargs[arg_name] 552 553 def as_dict(self) -> Dict[str, Any]: 554 return dict(self._kwargs) 555 556 557ConfigResultObjectT = TypeVar("ConfigResultObjectT", bound="object") 558 559class ConfigParser(Generic[ConfigResultObjectT]): 560 561 def __init__(self, 562 title: str, 563 cls: Type[ConfigResultObjectT], 564 default: Optional[ConfigResultObjectT] = None, 565 allow_unused_config_data: bool = True) -> None: 566 self.title = title 567 assert title, "No title provided" 568 self._cls = cls 569 if default: 570 if not isinstance(default, cls): 571 raise TypeError( 572 f"Default value '{default}' is not an instance of {cls.__name__}") 573 self._default = default 574 self._args: Dict[str, _ConfigArgParser] = {} 575 self._arg_names: Set[str] = set() 576 self._allow_unused_config_data = allow_unused_config_data 577 578 @property 579 def default(self) -> Optional[ConfigResultObjectT]: 580 return self._default 581 582 def add_argument( # pylint: disable=redefined-builtin 583 self, 584 name: str, 585 type: Optional[ArgParserType], 586 default: Optional[Any] = NOT_SET, 587 choices: Optional[Iterable[Any]] = None, 588 aliases: Tuple[str, ...] = tuple(), 589 help: Optional[str] = None, 590 is_list: bool = False, 591 required: bool = False, 592 depends_on: Optional[Iterable[str]] = None) -> None: 593 if name in self._arg_names: 594 raise ValueError(f"Duplicate argument: {name}") 595 arg = self._args[name] = _ConfigArgParser(self, name, type, default, 596 choices, aliases, help, is_list, 597 required, depends_on) 598 self._arg_names.add(name) 599 for alias in arg.aliases: 600 if alias in self._arg_names: 601 raise ValueError(f"Argument alias ({alias}) from {name}" 602 " was previously added as argument.") 603 self._arg_names.add(alias) 604 605 def get_argument(self, arg_name: str) -> _ConfigArgParser: 606 return self._args[arg_name] 607 608 def kwargs_from_config(self, config_data: Dict[str, Any], 609 **extra_kwargs) -> Dict[str, Any]: 610 with exception.annotate_argparsing( 611 f"Parsing {self._cls.__name__} extra config kwargs:"): 612 config_data = self._prepare_config_data(config_data, **extra_kwargs) 613 with exception.annotate_argparsing( 614 f"Parsing {self._cls.__name__} config dict:"): 615 kwargs = _ConfigKwargsParser(self, config_data) 616 if config_data: 617 self._handle_unused_config_data(config_data) 618 return kwargs.as_dict() 619 raise exception.UnreachableError() 620 621 def parse(self, config_data: Dict[str, Any], **kwargs) -> ConfigResultObjectT: 622 if self._default and config_data == {} and not kwargs: 623 return self._default 624 kwargs = self.kwargs_from_config(config_data, **kwargs) 625 return self.new_instance_from_kwargs(kwargs) 626 627 def _prepare_config_data(self, config_data: Dict[str, Any], 628 **extra_kwargs) -> Dict[str, Any]: 629 config_data = dict(config_data) 630 for extra_key, extra_data in extra_kwargs.items(): 631 if extra_data is None: 632 continue 633 if extra_key in config_data and extra_data is not config_data[extra_key]: 634 raise ValueError( 635 f"Extra config data {repr(extra_key)}={repr(extra_data)} " 636 "was already present in " 637 f"config_data[..]={repr(config_data[extra_key])}") 638 config_data[extra_key] = extra_data 639 return config_data 640 641 def new_instance_from_kwargs(self, kwargs: Dict[str, 642 Any]) -> ConfigResultObjectT: 643 return self._cls(**kwargs) 644 645 def _handle_unused_config_data(self, unused_config_data: Dict[str, 646 Any]) -> None: 647 logging.debug("Got unused properties: %s", unused_config_data.keys()) 648 if not self._allow_unused_config_data: 649 unused_keys = ", ".join(map(repr, unused_config_data.keys())) 650 raise argparse.ArgumentTypeError( 651 f"Config for {self._cls.__name__} contains unused properties: " 652 f"{unused_keys}") 653 654 @property 655 def arg_parsers(self) -> Tuple[_ConfigArgParser, ...]: 656 return tuple(self._args.values()) 657 658 @property 659 def cls(self) -> Type: 660 return self._cls 661 662 @property 663 def doc(self) -> str: 664 if not self._cls.__doc__: 665 return "" 666 return self._cls.__doc__.strip() 667 668 @property 669 def help(self) -> str: 670 return str(self) 671 672 @property 673 def summary(self) -> str: 674 return self.doc.splitlines()[0] 675 676 def __str__(self) -> str: 677 parts: List[str] = [] 678 doc_string = self.doc 679 width = 80 680 if doc_string: 681 parts.append("\n".join(textwrap.wrap(doc_string, width=width))) 682 parts.append("") 683 if not self._args: 684 if parts: 685 return parts[0] 686 return "" 687 parts.append(f"{self.title} Configuration:") 688 parts.append("") 689 for arg in self._args.values(): 690 parts.append(f"{arg.name}:") 691 parts.extend(helper.wrap_lines(arg.help_text, width=width, indent=" ")) 692 parts.append("") 693 return "\n".join(parts) 694 695 696def is_google_env() -> bool: 697 return "/google3/" in __file__ 698 699 700def root_dir() -> pth.LocalPath: 701 if is_google_env(): 702 return pth.LocalPath(__file__).parents[0] 703 return pth.LocalPath(__file__).parents[1] 704 705 706def config_dir() -> pth.LocalPath: 707 return root_dir() / "config" 708