• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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