config.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555
  1. # Copyright (c) 2018-present, Facebook, Inc.
  2. #
  3. # Licensed under the Apache License, Version 2.0 (the "License");
  4. # you may not use this file except in compliance with the License.
  5. # You may obtain a copy of the License at
  6. #
  7. # http://www.apache.org/licenses/LICENSE-2.0
  8. #
  9. # Unless required by applicable law or agreed to in writing, software
  10. # distributed under the License is distributed on an "AS IS" BASIS,
  11. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  12. # See the License for the specific language governing permissions and
  13. # limitations under the License.
  14. ##############################################################################
  15. """YACS -- Yet Another Configuration System is designed to be a simple
  16. configuration management system for academic and industrial research
  17. projects.
  18. See README.md for usage and examples.
  19. """
  20. import copy
  21. import io
  22. import logging
  23. import os
  24. import sys
  25. from ast import literal_eval
  26. import yaml
  27. # Flag for py2 and py3 compatibility to use when separate code paths are necessary
  28. # When _PY2 is False, we assume Python 3 is in use
  29. _PY2 = sys.version_info.major == 2
  30. # Filename extensions for loading configs from files
  31. _YAML_EXTS = {"", ".yaml", ".yml"}
  32. _PY_EXTS = {".py"}
  33. # py2 and py3 compatibility for checking file object type
  34. # We simply use this to infer py2 vs py3
  35. if _PY2:
  36. _FILE_TYPES = (file, io.IOBase)
  37. else:
  38. _FILE_TYPES = (io.IOBase,)
  39. # CfgNodes can only contain a limited set of valid types
  40. _VALID_TYPES = {tuple, list, str, int, float, bool, type(None)}
  41. # py2 allow for str and unicode
  42. if _PY2:
  43. _VALID_TYPES = _VALID_TYPES.union({unicode}) # noqa: F821
  44. # Utilities for importing modules from file paths
  45. if _PY2:
  46. # imp is available in both py2 and py3 for now, but is deprecated in py3
  47. import imp
  48. else:
  49. import importlib.util
  50. logger = logging.getLogger(__name__)
  51. class CfgNode(dict):
  52. """
  53. CfgNode represents an internal node in the configuration tree. It's a simple
  54. dict-like container that allows for attribute-based access to keys.
  55. """
  56. IMMUTABLE = "__immutable__"
  57. DEPRECATED_KEYS = "__deprecated_keys__"
  58. RENAMED_KEYS = "__renamed_keys__"
  59. NEW_ALLOWED = "__new_allowed__"
  60. def __init__(self, init_dict=None, key_list=None, new_allowed=False):
  61. """
  62. Args:
  63. init_dict (dict): the possibly-nested dictionary to initailize the CfgNode.
  64. key_list (list[str]): a list of names which index this CfgNode from the root.
  65. Currently only used for logging purposes.
  66. new_allowed (bool): whether adding new key is allowed when merging with
  67. other configs.
  68. """
  69. # Recursively convert nested dictionaries in init_dict into CfgNodes
  70. init_dict = {} if init_dict is None else init_dict
  71. key_list = [] if key_list is None else key_list
  72. init_dict = self._create_config_tree_from_dict(init_dict, key_list)
  73. super(CfgNode, self).__init__(init_dict)
  74. # Manage if the CfgNode is frozen or not
  75. self.__dict__[CfgNode.IMMUTABLE] = False
  76. # Deprecated options
  77. # If an option is removed from the code and you don't want to break existing
  78. # yaml configs, you can add the full config key as a string to the set below.
  79. self.__dict__[CfgNode.DEPRECATED_KEYS] = set()
  80. # Renamed options
  81. # If you rename a config option, record the mapping from the old name to the new
  82. # name in the dictionary below. Optionally, if the type also changed, you can
  83. # make the value a tuple that specifies first the renamed key and then
  84. # instructions for how to edit the config file.
  85. self.__dict__[CfgNode.RENAMED_KEYS] = {
  86. # 'EXAMPLE.OLD.KEY': 'EXAMPLE.NEW.KEY', # Dummy example to follow
  87. # 'EXAMPLE.OLD.KEY': ( # A more complex example to follow
  88. # 'EXAMPLE.NEW.KEY',
  89. # "Also convert to a tuple, e.g., 'foo' -> ('foo',) or "
  90. # + "'foo:bar' -> ('foo', 'bar')"
  91. # ),
  92. }
  93. # Allow new attributes after initialisation
  94. self.__dict__[CfgNode.NEW_ALLOWED] = new_allowed
  95. @classmethod
  96. def _create_config_tree_from_dict(cls, dic, key_list):
  97. """
  98. Create a configuration tree using the given dict.
  99. Any dict-like objects inside dict will be treated as a new CfgNode.
  100. Args:
  101. dic (dict):
  102. key_list (list[str]): a list of names which index this CfgNode from the root.
  103. Currently only used for logging purposes.
  104. """
  105. dic = copy.deepcopy(dic)
  106. for k, v in dic.items():
  107. if isinstance(v, dict):
  108. # Convert dict to CfgNode
  109. dic[k] = cls(v, key_list=key_list + [k])
  110. else:
  111. # Check for valid leaf type or nested CfgNode
  112. _assert_with_logging(
  113. _valid_type(v, allow_cfg_node=False),
  114. "Key {} with value {} is not a valid type; valid types: {}".format(
  115. ".".join(key_list + [str(k)]), type(v), _VALID_TYPES
  116. ),
  117. )
  118. return dic
  119. def __getattr__(self, name):
  120. if name in self:
  121. return self[name]
  122. else:
  123. raise AttributeError(name)
  124. def __setattr__(self, name, value):
  125. if self.is_frozen():
  126. raise AttributeError(
  127. "Attempted to set {} to {}, but CfgNode is immutable".format(
  128. name, value
  129. )
  130. )
  131. _assert_with_logging(
  132. name not in self.__dict__,
  133. "Invalid attempt to modify internal CfgNode state: {}".format(name),
  134. )
  135. _assert_with_logging(
  136. _valid_type(value, allow_cfg_node=True),
  137. "Invalid type {} for key {}; valid types = {}".format(
  138. type(value), name, _VALID_TYPES
  139. ),
  140. )
  141. self[name] = value
  142. def __str__(self):
  143. def _indent(s_, num_spaces):
  144. s = s_.split("\n")
  145. if len(s) == 1:
  146. return s_
  147. first = s.pop(0)
  148. s = [(num_spaces * " ") + line for line in s]
  149. s = "\n".join(s)
  150. s = first + "\n" + s
  151. return s
  152. r = ""
  153. s = []
  154. for k, v in sorted(self.items()):
  155. seperator = "\n" if isinstance(v, CfgNode) else " "
  156. attr_str = "{}:{}{}".format(str(k), seperator, str(v))
  157. attr_str = _indent(attr_str, 2)
  158. s.append(attr_str)
  159. r += "\n".join(s)
  160. return r
  161. def __repr__(self):
  162. return "{}({})".format(self.__class__.__name__, super(CfgNode, self).__repr__())
  163. def dump(self, **kwargs):
  164. """Dump to a string."""
  165. def convert_to_dict(cfg_node, key_list):
  166. if not isinstance(cfg_node, CfgNode):
  167. _assert_with_logging(
  168. _valid_type(cfg_node),
  169. "Key {} with value {} is not a valid type; valid types: {}".format(
  170. ".".join(key_list), type(cfg_node), _VALID_TYPES
  171. ),
  172. )
  173. return cfg_node
  174. else:
  175. cfg_dict = dict(cfg_node)
  176. for k, v in cfg_dict.items():
  177. cfg_dict[k] = convert_to_dict(v, key_list + [k])
  178. return cfg_dict
  179. self_as_dict = convert_to_dict(self, [])
  180. return yaml.safe_dump(self_as_dict, **kwargs)
  181. def merge_from_file(self, cfg_filename):
  182. """Load a yaml config file and merge it this CfgNode."""
  183. with open(cfg_filename, "r") as f:
  184. cfg = self.load_cfg(f)
  185. self.merge_from_other_cfg(cfg)
  186. def merge_from_other_cfg(self, cfg_other):
  187. """Merge `cfg_other` into this CfgNode."""
  188. _merge_a_into_b(cfg_other, self, self, [])
  189. def merge_from_list(self, cfg_list):
  190. """Merge config (keys, values) in a list (e.g., from command line) into
  191. this CfgNode. For example, `cfg_list = ['FOO.BAR', 0.5]`.
  192. """
  193. _assert_with_logging(
  194. len(cfg_list) % 2 == 0,
  195. "Override list has odd length: {}; it must be a list of pairs".format(
  196. cfg_list
  197. ),
  198. )
  199. root = self
  200. for full_key, v in zip(cfg_list[0::2], cfg_list[1::2]):
  201. if root.key_is_deprecated(full_key):
  202. continue
  203. if root.key_is_renamed(full_key):
  204. root.raise_key_rename_error(full_key)
  205. key_list = full_key.split(".")
  206. d = self
  207. for subkey in key_list[:-1]:
  208. _assert_with_logging(
  209. subkey in d, "Non-existent key: {}".format(full_key)
  210. )
  211. d = d[subkey]
  212. subkey = key_list[-1]
  213. _assert_with_logging(subkey in d, "Non-existent key: {}".format(full_key))
  214. value = self._decode_cfg_value(v)
  215. value = _check_and_coerce_cfg_value_type(value, d[subkey], subkey, full_key)
  216. d[subkey] = value
  217. def freeze(self):
  218. """Make this CfgNode and all of its children immutable."""
  219. self._immutable(True)
  220. def defrost(self):
  221. """Make this CfgNode and all of its children mutable."""
  222. self._immutable(False)
  223. def is_frozen(self):
  224. """Return mutability."""
  225. return self.__dict__[CfgNode.IMMUTABLE]
  226. def _immutable(self, is_immutable):
  227. """Set immutability to is_immutable and recursively apply the setting
  228. to all nested CfgNodes.
  229. """
  230. self.__dict__[CfgNode.IMMUTABLE] = is_immutable
  231. # Recursively set immutable state
  232. for v in self.__dict__.values():
  233. if isinstance(v, CfgNode):
  234. v._immutable(is_immutable)
  235. for v in self.values():
  236. if isinstance(v, CfgNode):
  237. v._immutable(is_immutable)
  238. def clone(self):
  239. """Recursively copy this CfgNode."""
  240. return copy.deepcopy(self)
  241. def register_deprecated_key(self, key):
  242. """Register key (e.g. `FOO.BAR`) a deprecated option. When merging deprecated
  243. keys a warning is generated and the key is ignored.
  244. """
  245. _assert_with_logging(
  246. key not in self.__dict__[CfgNode.DEPRECATED_KEYS],
  247. "key {} is already registered as a deprecated key".format(key),
  248. )
  249. self.__dict__[CfgNode.DEPRECATED_KEYS].add(key)
  250. def register_renamed_key(self, old_name, new_name, message=None):
  251. """Register a key as having been renamed from `old_name` to `new_name`.
  252. When merging a renamed key, an exception is thrown alerting to user to
  253. the fact that the key has been renamed.
  254. """
  255. _assert_with_logging(
  256. old_name not in self.__dict__[CfgNode.RENAMED_KEYS],
  257. "key {} is already registered as a renamed cfg key".format(old_name),
  258. )
  259. value = new_name
  260. if message:
  261. value = (new_name, message)
  262. self.__dict__[CfgNode.RENAMED_KEYS][old_name] = value
  263. def key_is_deprecated(self, full_key):
  264. """Test if a key is deprecated."""
  265. if full_key in self.__dict__[CfgNode.DEPRECATED_KEYS]:
  266. logger.warning("Deprecated config key (ignoring): {}".format(full_key))
  267. return True
  268. return False
  269. def key_is_renamed(self, full_key):
  270. """Test if a key is renamed."""
  271. return full_key in self.__dict__[CfgNode.RENAMED_KEYS]
  272. def raise_key_rename_error(self, full_key):
  273. new_key = self.__dict__[CfgNode.RENAMED_KEYS][full_key]
  274. if isinstance(new_key, tuple):
  275. msg = " Note: " + new_key[1]
  276. new_key = new_key[0]
  277. else:
  278. msg = ""
  279. raise KeyError(
  280. "Key {} was renamed to {}; please update your config.{}".format(
  281. full_key, new_key, msg
  282. )
  283. )
  284. def is_new_allowed(self):
  285. return self.__dict__[CfgNode.NEW_ALLOWED]
  286. def set_new_allowed(self, is_new_allowed):
  287. """
  288. Set this config (and recursively its subconfigs) to allow merging
  289. new keys from other configs.
  290. """
  291. self.__dict__[CfgNode.NEW_ALLOWED] = is_new_allowed
  292. # Recursively set new_allowed state
  293. for v in self.__dict__.values():
  294. if isinstance(v, CfgNode):
  295. v.set_new_allowed(is_new_allowed)
  296. for v in self.values():
  297. if isinstance(v, CfgNode):
  298. v.set_new_allowed(is_new_allowed)
  299. @classmethod
  300. def load_cfg(cls, cfg_file_obj_or_str):
  301. """
  302. Load a cfg.
  303. Args:
  304. cfg_file_obj_or_str (str or file):
  305. Supports loading from:
  306. - A file object backed by a YAML file
  307. - A file object backed by a Python source file that exports an attribute
  308. "cfg" that is either a dict or a CfgNode
  309. - A string that can be parsed as valid YAML
  310. """
  311. _assert_with_logging(
  312. isinstance(cfg_file_obj_or_str, _FILE_TYPES + (str,)),
  313. "Expected first argument to be of type {} or {}, but it was {}".format(
  314. _FILE_TYPES, str, type(cfg_file_obj_or_str)
  315. ),
  316. )
  317. if isinstance(cfg_file_obj_or_str, str):
  318. return cls._load_cfg_from_yaml_str(cfg_file_obj_or_str)
  319. elif isinstance(cfg_file_obj_or_str, _FILE_TYPES):
  320. return cls._load_cfg_from_file(cfg_file_obj_or_str)
  321. else:
  322. raise NotImplementedError("Impossible to reach here (unless there's a bug)")
  323. @classmethod
  324. def _load_cfg_from_file(cls, file_obj):
  325. """Load a config from a YAML file or a Python source file."""
  326. _, file_extension = os.path.splitext(file_obj.name)
  327. if file_extension in _YAML_EXTS:
  328. return cls._load_cfg_from_yaml_str(file_obj.read())
  329. elif file_extension in _PY_EXTS:
  330. return cls._load_cfg_py_source(file_obj.name)
  331. else:
  332. raise Exception(
  333. "Attempt to load from an unsupported file type {}; "
  334. "only {} are supported".format(file_obj, _YAML_EXTS.union(_PY_EXTS))
  335. )
  336. @classmethod
  337. def _load_cfg_from_yaml_str(cls, str_obj):
  338. """Load a config from a YAML string encoding."""
  339. cfg_as_dict = yaml.safe_load(str_obj)
  340. return cls(cfg_as_dict)
  341. @classmethod
  342. def _load_cfg_py_source(cls, filename):
  343. """Load a config from a Python source file."""
  344. module = _load_module_from_file("yacs.config.override", filename)
  345. _assert_with_logging(
  346. hasattr(module, "cfg"),
  347. "Python module from file {} must have 'cfg' attr".format(filename),
  348. )
  349. VALID_ATTR_TYPES = {dict, CfgNode}
  350. _assert_with_logging(
  351. type(module.cfg) in VALID_ATTR_TYPES,
  352. "Imported module 'cfg' attr must be in {} but is {} instead".format(
  353. VALID_ATTR_TYPES, type(module.cfg)
  354. ),
  355. )
  356. return cls(module.cfg)
  357. @classmethod
  358. def _decode_cfg_value(cls, value):
  359. """
  360. Decodes a raw config value (e.g., from a yaml config files or command
  361. line argument) into a Python object.
  362. If the value is a dict, it will be interpreted as a new CfgNode.
  363. If the value is a str, it will be evaluated as literals.
  364. Otherwise it is returned as-is.
  365. """
  366. # Configs parsed from raw yaml will contain dictionary keys that need to be
  367. # converted to CfgNode objects
  368. if isinstance(value, dict):
  369. return cls(value)
  370. # All remaining processing is only applied to strings
  371. if not isinstance(value, str):
  372. return value
  373. # Try to interpret `value` as a:
  374. # string, number, tuple, list, dict, boolean, or None
  375. try:
  376. value = literal_eval(value)
  377. # The following two excepts allow v to pass through when it represents a
  378. # string.
  379. #
  380. # Longer explanation:
  381. # The type of v is always a string (before calling literal_eval), but
  382. # sometimes it *represents* a string and other times a data structure, like
  383. # a list. In the case that v represents a string, what we got back from the
  384. # yaml parser is 'foo' *without quotes* (so, not '"foo"'). literal_eval is
  385. # ok with '"foo"', but will raise a ValueError if given 'foo'. In other
  386. # cases, like paths (v = 'foo/bar' and not v = '"foo/bar"'), literal_eval
  387. # will raise a SyntaxError.
  388. except ValueError:
  389. pass
  390. except SyntaxError:
  391. pass
  392. return value
  393. load_cfg = (
  394. CfgNode.load_cfg
  395. ) # keep this function in global scope for backward compatibility
  396. def _valid_type(value, allow_cfg_node=False):
  397. return (type(value) in _VALID_TYPES) or (
  398. allow_cfg_node and isinstance(value, CfgNode)
  399. )
  400. def _merge_a_into_b(a, b, root, key_list):
  401. """Merge config dictionary a into config dictionary b, clobbering the
  402. options in b whenever they are also specified in a.
  403. """
  404. _assert_with_logging(
  405. isinstance(a, CfgNode),
  406. "`a` (cur type {}) must be an instance of {}".format(type(a), CfgNode),
  407. )
  408. _assert_with_logging(
  409. isinstance(b, CfgNode),
  410. "`b` (cur type {}) must be an instance of {}".format(type(b), CfgNode),
  411. )
  412. for k, v_ in a.items():
  413. full_key = ".".join(key_list + [k])
  414. v = copy.deepcopy(v_)
  415. v = b._decode_cfg_value(v)
  416. if k in b:
  417. v = _check_and_coerce_cfg_value_type(v, b[k], k, full_key)
  418. # Recursively merge dicts
  419. if isinstance(v, CfgNode):
  420. try:
  421. _merge_a_into_b(v, b[k], root, key_list + [k])
  422. except BaseException:
  423. raise
  424. else:
  425. b[k] = v
  426. elif b.is_new_allowed():
  427. b[k] = v
  428. else:
  429. if root.key_is_deprecated(full_key):
  430. continue
  431. elif root.key_is_renamed(full_key):
  432. root.raise_key_rename_error(full_key)
  433. else:
  434. raise KeyError("Non-existent config key: {}".format(full_key))
  435. def _check_and_coerce_cfg_value_type(replacement, original, key, full_key):
  436. """Checks that `replacement`, which is intended to replace `original` is of
  437. the right type. The type is correct if it matches exactly or is one of a few
  438. cases in which the type can be easily coerced.
  439. """
  440. original_type = type(original)
  441. replacement_type = type(replacement)
  442. # The types must match (with some exceptions)
  443. if replacement_type == original_type:
  444. return replacement
  445. # If either of them is None, allow type conversion to one of the valid types
  446. if (replacement_type == type(None) and original_type in _VALID_TYPES) or (
  447. original_type == type(None) and replacement_type in _VALID_TYPES
  448. ):
  449. return replacement
  450. # Cast replacement from from_type to to_type if the replacement and original
  451. # types match from_type and to_type
  452. def conditional_cast(from_type, to_type):
  453. if replacement_type == from_type and original_type == to_type:
  454. return True, to_type(replacement)
  455. else:
  456. return False, None
  457. # Conditionally casts
  458. # list <-> tuple
  459. casts = [(tuple, list), (list, tuple)]
  460. # For py2: allow converting from str (bytes) to a unicode string
  461. try:
  462. casts.append((str, unicode)) # noqa: F821
  463. except Exception:
  464. pass
  465. for (from_type, to_type) in casts:
  466. converted, converted_value = conditional_cast(from_type, to_type)
  467. if converted:
  468. return converted_value
  469. raise ValueError(
  470. "Type mismatch ({} vs. {}) with values ({} vs. {}) for config "
  471. "key: {}".format(
  472. original_type, replacement_type, original, replacement, full_key
  473. )
  474. )
  475. def _assert_with_logging(cond, msg):
  476. if not cond:
  477. logger.debug(msg)
  478. assert cond, msg
  479. def _load_module_from_file(name, filename):
  480. if _PY2:
  481. module = imp.load_source(name, filename)
  482. else:
  483. spec = importlib.util.spec_from_file_location(name, filename)
  484. module = importlib.util.module_from_spec(spec)
  485. spec.loader.exec_module(module)
  486. return module