box.py 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110
  1. #!/usr/bin/env python
  2. # -*- coding: UTF-8 -*-
  3. #
  4. # Copyright (c) 2017-2019 - Chris Griffith - MIT License
  5. """
  6. Improved dictionary access through dot notation with additional tools.
  7. """
  8. import string
  9. import sys
  10. import json
  11. import re
  12. import copy
  13. from keyword import kwlist
  14. import warnings
  15. try:
  16. from collections.abc import Iterable, Mapping, Callable
  17. except ImportError:
  18. from collections import Iterable, Mapping, Callable
  19. yaml_support = True
  20. try:
  21. import yaml
  22. except ImportError:
  23. try:
  24. import ruamel.yaml as yaml
  25. except ImportError:
  26. yaml = None
  27. yaml_support = False
  28. if sys.version_info >= (3, 0):
  29. basestring = str
  30. else:
  31. from io import open
  32. __all__ = ['Box', 'ConfigBox', 'BoxList', 'SBox',
  33. 'BoxError', 'BoxKeyError']
  34. __author__ = 'Chris Griffith'
  35. __version__ = '3.2.4'
  36. BOX_PARAMETERS = ('default_box', 'default_box_attr', 'conversion_box',
  37. 'frozen_box', 'camel_killer_box', 'box_it_up',
  38. 'box_safe_prefix', 'box_duplicates', 'ordered_box')
  39. _first_cap_re = re.compile('(.)([A-Z][a-z]+)')
  40. _all_cap_re = re.compile('([a-z0-9])([A-Z])')
  41. class BoxError(Exception):
  42. """Non standard dictionary exceptions"""
  43. class BoxKeyError(BoxError, KeyError, AttributeError):
  44. """Key does not exist"""
  45. # Abstract converter functions for use in any Box class
  46. def _to_json(obj, filename=None,
  47. encoding="utf-8", errors="strict", **json_kwargs):
  48. json_dump = json.dumps(obj,
  49. ensure_ascii=False, **json_kwargs)
  50. if filename:
  51. with open(filename, 'w', encoding=encoding, errors=errors) as f:
  52. f.write(json_dump if sys.version_info >= (3, 0) else
  53. json_dump.decode("utf-8"))
  54. else:
  55. return json_dump
  56. def _from_json(json_string=None, filename=None,
  57. encoding="utf-8", errors="strict", multiline=False, **kwargs):
  58. if filename:
  59. with open(filename, 'r', encoding=encoding, errors=errors) as f:
  60. if multiline:
  61. data = [json.loads(line.strip(), **kwargs) for line in f
  62. if line.strip() and not line.strip().startswith("#")]
  63. else:
  64. data = json.load(f, **kwargs)
  65. elif json_string:
  66. data = json.loads(json_string, **kwargs)
  67. else:
  68. raise BoxError('from_json requires a string or filename')
  69. return data
  70. def _to_yaml(obj, filename=None, default_flow_style=False,
  71. encoding="utf-8", errors="strict",
  72. **yaml_kwargs):
  73. if filename:
  74. with open(filename, 'w',
  75. encoding=encoding, errors=errors) as f:
  76. yaml.dump(obj, stream=f,
  77. default_flow_style=default_flow_style,
  78. **yaml_kwargs)
  79. else:
  80. return yaml.dump(obj,
  81. default_flow_style=default_flow_style,
  82. **yaml_kwargs)
  83. def _from_yaml(yaml_string=None, filename=None,
  84. encoding="utf-8", errors="strict",
  85. **kwargs):
  86. if filename:
  87. with open(filename, 'r',
  88. encoding=encoding, errors=errors) as f:
  89. data = yaml.load(f, **kwargs)
  90. elif yaml_string:
  91. data = yaml.load(yaml_string, **kwargs)
  92. else:
  93. raise BoxError('from_yaml requires a string or filename')
  94. return data
  95. # Helper functions
  96. def _safe_key(key):
  97. try:
  98. return str(key)
  99. except UnicodeEncodeError:
  100. return key.encode("utf-8", "ignore")
  101. def _safe_attr(attr, camel_killer=False, replacement_char='x'):
  102. """Convert a key into something that is accessible as an attribute"""
  103. allowed = string.ascii_letters + string.digits + '_'
  104. attr = _safe_key(attr)
  105. if camel_killer:
  106. attr = _camel_killer(attr)
  107. attr = attr.replace(' ', '_')
  108. out = ''
  109. for character in attr:
  110. out += character if character in allowed else "_"
  111. out = out.strip("_")
  112. try:
  113. int(out[0])
  114. except (ValueError, IndexError):
  115. pass
  116. else:
  117. out = '{0}{1}'.format(replacement_char, out)
  118. if out in kwlist:
  119. out = '{0}{1}'.format(replacement_char, out)
  120. return re.sub('_+', '_', out)
  121. def _camel_killer(attr):
  122. """
  123. CamelKiller, qu'est-ce que c'est?
  124. Taken from http://stackoverflow.com/a/1176023/3244542
  125. """
  126. try:
  127. attr = str(attr)
  128. except UnicodeEncodeError:
  129. attr = attr.encode("utf-8", "ignore")
  130. s1 = _first_cap_re.sub(r'\1_\2', attr)
  131. s2 = _all_cap_re.sub(r'\1_\2', s1)
  132. return re.sub('_+', '_', s2.casefold() if hasattr(s2, 'casefold') else
  133. s2.lower())
  134. def _recursive_tuples(iterable, box_class, recreate_tuples=False, **kwargs):
  135. out_list = []
  136. for i in iterable:
  137. if isinstance(i, dict):
  138. out_list.append(box_class(i, **kwargs))
  139. elif isinstance(i, list) or (recreate_tuples and isinstance(i, tuple)):
  140. out_list.append(_recursive_tuples(i, box_class,
  141. recreate_tuples, **kwargs))
  142. else:
  143. out_list.append(i)
  144. return tuple(out_list)
  145. def _conversion_checks(item, keys, box_config, check_only=False,
  146. pre_check=False):
  147. """
  148. Internal use for checking if a duplicate safe attribute already exists
  149. :param item: Item to see if a dup exists
  150. :param keys: Keys to check against
  151. :param box_config: Easier to pass in than ask for specfic items
  152. :param check_only: Don't bother doing the conversion work
  153. :param pre_check: Need to add the item to the list of keys to check
  154. :return: the original unmodified key, if exists and not check_only
  155. """
  156. if box_config['box_duplicates'] != 'ignore':
  157. if pre_check:
  158. keys = list(keys) + [item]
  159. key_list = [(k,
  160. _safe_attr(k, camel_killer=box_config['camel_killer_box'],
  161. replacement_char=box_config['box_safe_prefix']
  162. )) for k in keys]
  163. if len(key_list) > len(set(x[1] for x in key_list)):
  164. seen = set()
  165. dups = set()
  166. for x in key_list:
  167. if x[1] in seen:
  168. dups.add("{0}({1})".format(x[0], x[1]))
  169. seen.add(x[1])
  170. if box_config['box_duplicates'].startswith("warn"):
  171. warnings.warn('Duplicate conversion attributes exist: '
  172. '{0}'.format(dups))
  173. else:
  174. raise BoxError('Duplicate conversion attributes exist: '
  175. '{0}'.format(dups))
  176. if check_only:
  177. return
  178. # This way will be slower for warnings, as it will have double work
  179. # But faster for the default 'ignore'
  180. for k in keys:
  181. if item == _safe_attr(k, camel_killer=box_config['camel_killer_box'],
  182. replacement_char=box_config['box_safe_prefix']):
  183. return k
  184. def _get_box_config(cls, kwargs):
  185. return {
  186. # Internal use only
  187. '__converted': set(),
  188. '__box_heritage': kwargs.pop('__box_heritage', None),
  189. '__created': False,
  190. '__ordered_box_values': [],
  191. # Can be changed by user after box creation
  192. 'default_box': kwargs.pop('default_box', False),
  193. 'default_box_attr': kwargs.pop('default_box_attr', cls),
  194. 'conversion_box': kwargs.pop('conversion_box', True),
  195. 'box_safe_prefix': kwargs.pop('box_safe_prefix', 'x'),
  196. 'frozen_box': kwargs.pop('frozen_box', False),
  197. 'camel_killer_box': kwargs.pop('camel_killer_box', False),
  198. 'modify_tuples_box': kwargs.pop('modify_tuples_box', False),
  199. 'box_duplicates': kwargs.pop('box_duplicates', 'ignore'),
  200. 'ordered_box': kwargs.pop('ordered_box', False)
  201. }
  202. class Box(dict):
  203. """
  204. Improved dictionary access through dot notation with additional tools.
  205. :param default_box: Similar to defaultdict, return a default value
  206. :param default_box_attr: Specify the default replacement.
  207. WARNING: If this is not the default 'Box', it will not be recursive
  208. :param frozen_box: After creation, the box cannot be modified
  209. :param camel_killer_box: Convert CamelCase to snake_case
  210. :param conversion_box: Check for near matching keys as attributes
  211. :param modify_tuples_box: Recreate incoming tuples with dicts into Boxes
  212. :param box_it_up: Recursively create all Boxes from the start
  213. :param box_safe_prefix: Conversion box prefix for unsafe attributes
  214. :param box_duplicates: "ignore", "error" or "warn" when duplicates exists
  215. in a conversion_box
  216. :param ordered_box: Preserve the order of keys entered into the box
  217. """
  218. _protected_keys = dir({}) + ['to_dict', 'tree_view', 'to_json', 'to_yaml',
  219. 'from_yaml', 'from_json']
  220. def __new__(cls, *args, **kwargs):
  221. """
  222. Due to the way pickling works in python 3, we need to make sure
  223. the box config is created as early as possible.
  224. """
  225. obj = super(Box, cls).__new__(cls, *args, **kwargs)
  226. obj._box_config = _get_box_config(cls, kwargs)
  227. return obj
  228. def __init__(self, *args, **kwargs):
  229. self._box_config = _get_box_config(self.__class__, kwargs)
  230. if self._box_config['ordered_box']:
  231. self._box_config['__ordered_box_values'] = []
  232. if (not self._box_config['conversion_box'] and
  233. self._box_config['box_duplicates'] != "ignore"):
  234. raise BoxError('box_duplicates are only for conversion_boxes')
  235. if len(args) == 1:
  236. if isinstance(args[0], basestring):
  237. raise ValueError('Cannot extrapolate Box from string')
  238. if isinstance(args[0], Mapping):
  239. for k, v in args[0].items():
  240. if v is args[0]:
  241. v = self
  242. self[k] = v
  243. self.__add_ordered(k)
  244. elif isinstance(args[0], Iterable):
  245. for k, v in args[0]:
  246. self[k] = v
  247. self.__add_ordered(k)
  248. else:
  249. raise ValueError('First argument must be mapping or iterable')
  250. elif args:
  251. raise TypeError('Box expected at most 1 argument, '
  252. 'got {0}'.format(len(args)))
  253. box_it = kwargs.pop('box_it_up', False)
  254. for k, v in kwargs.items():
  255. if args and isinstance(args[0], Mapping) and v is args[0]:
  256. v = self
  257. self[k] = v
  258. self.__add_ordered(k)
  259. if (self._box_config['frozen_box'] or box_it or
  260. self._box_config['box_duplicates'] != 'ignore'):
  261. self.box_it_up()
  262. self._box_config['__created'] = True
  263. def __add_ordered(self, key):
  264. if (self._box_config['ordered_box'] and
  265. key not in self._box_config['__ordered_box_values']):
  266. self._box_config['__ordered_box_values'].append(key)
  267. def box_it_up(self):
  268. """
  269. Perform value lookup for all items in current dictionary,
  270. generating all sub Box objects, while also running `box_it_up` on
  271. any of those sub box objects.
  272. """
  273. for k in self:
  274. _conversion_checks(k, self.keys(), self._box_config,
  275. check_only=True)
  276. if self[k] is not self and hasattr(self[k], 'box_it_up'):
  277. self[k].box_it_up()
  278. def __hash__(self):
  279. if self._box_config['frozen_box']:
  280. hashing = 54321
  281. for item in self.items():
  282. hashing ^= hash(item)
  283. return hashing
  284. raise TypeError("unhashable type: 'Box'")
  285. def __dir__(self):
  286. allowed = string.ascii_letters + string.digits + '_'
  287. kill_camel = self._box_config['camel_killer_box']
  288. items = set(dir(dict) + ['to_dict', 'to_json',
  289. 'from_json', 'box_it_up'])
  290. # Only show items accessible by dot notation
  291. for key in self.keys():
  292. key = _safe_key(key)
  293. if (' ' not in key and key[0] not in string.digits and
  294. key not in kwlist):
  295. for letter in key:
  296. if letter not in allowed:
  297. break
  298. else:
  299. items.add(key)
  300. for key in self.keys():
  301. key = _safe_key(key)
  302. if key not in items:
  303. if self._box_config['conversion_box']:
  304. key = _safe_attr(key, camel_killer=kill_camel,
  305. replacement_char=self._box_config[
  306. 'box_safe_prefix'])
  307. if key:
  308. items.add(key)
  309. if kill_camel:
  310. snake_key = _camel_killer(key)
  311. if snake_key:
  312. items.remove(key)
  313. items.add(snake_key)
  314. if yaml_support:
  315. items.add('to_yaml')
  316. items.add('from_yaml')
  317. return list(items)
  318. def get(self, key, default=None):
  319. try:
  320. return self[key]
  321. except KeyError:
  322. if isinstance(default, dict) and not isinstance(default, Box):
  323. return Box(default)
  324. if isinstance(default, list) and not isinstance(default, BoxList):
  325. return BoxList(default)
  326. return default
  327. def copy(self):
  328. return self.__class__(super(self.__class__, self).copy())
  329. def __copy__(self):
  330. return self.__class__(super(self.__class__, self).copy())
  331. def __deepcopy__(self, memodict=None):
  332. out = self.__class__()
  333. memodict = memodict or {}
  334. memodict[id(self)] = out
  335. for k, v in self.items():
  336. out[copy.deepcopy(k, memodict)] = copy.deepcopy(v, memodict)
  337. return out
  338. def __setstate__(self, state):
  339. self._box_config = state['_box_config']
  340. self.__dict__.update(state)
  341. def __getitem__(self, item, _ignore_default=False):
  342. try:
  343. value = super(Box, self).__getitem__(item)
  344. except KeyError as err:
  345. if item == '_box_config':
  346. raise BoxKeyError('_box_config should only exist as an '
  347. 'attribute and is never defaulted')
  348. if self._box_config['default_box'] and not _ignore_default:
  349. return self.__get_default(item)
  350. raise BoxKeyError(str(err))
  351. else:
  352. return self.__convert_and_store(item, value)
  353. def keys(self):
  354. if self._box_config['ordered_box']:
  355. return self._box_config['__ordered_box_values']
  356. return super(Box, self).keys()
  357. def values(self):
  358. return [self[x] for x in self.keys()]
  359. def items(self):
  360. return [(x, self[x]) for x in self.keys()]
  361. def __get_default(self, item):
  362. default_value = self._box_config['default_box_attr']
  363. if default_value is self.__class__:
  364. return self.__class__(__box_heritage=(self, item),
  365. **self.__box_config())
  366. elif isinstance(default_value, Callable):
  367. return default_value()
  368. elif hasattr(default_value, 'copy'):
  369. return default_value.copy()
  370. return default_value
  371. def __box_config(self):
  372. out = {}
  373. for k, v in self._box_config.copy().items():
  374. if not k.startswith("__"):
  375. out[k] = v
  376. return out
  377. def __convert_and_store(self, item, value):
  378. if item in self._box_config['__converted']:
  379. return value
  380. if isinstance(value, dict) and not isinstance(value, Box):
  381. value = self.__class__(value, __box_heritage=(self, item),
  382. **self.__box_config())
  383. self[item] = value
  384. elif isinstance(value, list) and not isinstance(value, BoxList):
  385. if self._box_config['frozen_box']:
  386. value = _recursive_tuples(value, self.__class__,
  387. recreate_tuples=self._box_config[
  388. 'modify_tuples_box'],
  389. __box_heritage=(self, item),
  390. **self.__box_config())
  391. else:
  392. value = BoxList(value, __box_heritage=(self, item),
  393. box_class=self.__class__,
  394. **self.__box_config())
  395. self[item] = value
  396. elif (self._box_config['modify_tuples_box'] and
  397. isinstance(value, tuple)):
  398. value = _recursive_tuples(value, self.__class__,
  399. recreate_tuples=True,
  400. __box_heritage=(self, item),
  401. **self.__box_config())
  402. self[item] = value
  403. self._box_config['__converted'].add(item)
  404. return value
  405. def __create_lineage(self):
  406. if (self._box_config['__box_heritage'] and
  407. self._box_config['__created']):
  408. past, item = self._box_config['__box_heritage']
  409. if not past[item]:
  410. past[item] = self
  411. self._box_config['__box_heritage'] = None
  412. def __getattr__(self, item):
  413. try:
  414. try:
  415. value = self.__getitem__(item, _ignore_default=True)
  416. except KeyError:
  417. value = object.__getattribute__(self, item)
  418. except AttributeError as err:
  419. if item == "__getstate__":
  420. raise AttributeError(item)
  421. if item == '_box_config':
  422. raise BoxError('_box_config key must exist')
  423. kill_camel = self._box_config['camel_killer_box']
  424. if self._box_config['conversion_box'] and item:
  425. k = _conversion_checks(item, self.keys(), self._box_config)
  426. if k:
  427. return self.__getitem__(k)
  428. if kill_camel:
  429. for k in self.keys():
  430. if item == _camel_killer(k):
  431. return self.__getitem__(k)
  432. if self._box_config['default_box']:
  433. return self.__get_default(item)
  434. raise BoxKeyError(str(err))
  435. else:
  436. if item == '_box_config':
  437. return value
  438. return self.__convert_and_store(item, value)
  439. def __setitem__(self, key, value):
  440. if (key != '_box_config' and self._box_config['__created'] and
  441. self._box_config['frozen_box']):
  442. raise BoxError('Box is frozen')
  443. if self._box_config['conversion_box']:
  444. _conversion_checks(key, self.keys(), self._box_config,
  445. check_only=True, pre_check=True)
  446. super(Box, self).__setitem__(key, value)
  447. self.__add_ordered(key)
  448. self.__create_lineage()
  449. def __setattr__(self, key, value):
  450. if (key != '_box_config' and self._box_config['frozen_box'] and
  451. self._box_config['__created']):
  452. raise BoxError('Box is frozen')
  453. if key in self._protected_keys:
  454. raise AttributeError("Key name '{0}' is protected".format(key))
  455. if key == '_box_config':
  456. return object.__setattr__(self, key, value)
  457. try:
  458. object.__getattribute__(self, key)
  459. except (AttributeError, UnicodeEncodeError):
  460. if (key not in self.keys() and
  461. (self._box_config['conversion_box'] or
  462. self._box_config['camel_killer_box'])):
  463. if self._box_config['conversion_box']:
  464. k = _conversion_checks(key, self.keys(),
  465. self._box_config)
  466. self[key if not k else k] = value
  467. elif self._box_config['camel_killer_box']:
  468. for each_key in self:
  469. if key == _camel_killer(each_key):
  470. self[each_key] = value
  471. break
  472. else:
  473. self[key] = value
  474. else:
  475. object.__setattr__(self, key, value)
  476. self.__add_ordered(key)
  477. self.__create_lineage()
  478. def __delitem__(self, key):
  479. if self._box_config['frozen_box']:
  480. raise BoxError('Box is frozen')
  481. super(Box, self).__delitem__(key)
  482. if (self._box_config['ordered_box'] and
  483. key in self._box_config['__ordered_box_values']):
  484. self._box_config['__ordered_box_values'].remove(key)
  485. def __delattr__(self, item):
  486. if self._box_config['frozen_box']:
  487. raise BoxError('Box is frozen')
  488. if item == '_box_config':
  489. raise BoxError('"_box_config" is protected')
  490. if item in self._protected_keys:
  491. raise AttributeError("Key name '{0}' is protected".format(item))
  492. try:
  493. object.__getattribute__(self, item)
  494. except AttributeError:
  495. del self[item]
  496. else:
  497. object.__delattr__(self, item)
  498. if (self._box_config['ordered_box'] and
  499. item in self._box_config['__ordered_box_values']):
  500. self._box_config['__ordered_box_values'].remove(item)
  501. def pop(self, key, *args):
  502. if args:
  503. if len(args) != 1:
  504. raise BoxError('pop() takes only one optional'
  505. ' argument "default"')
  506. try:
  507. item = self[key]
  508. except KeyError:
  509. return args[0]
  510. else:
  511. del self[key]
  512. return item
  513. try:
  514. item = self[key]
  515. except KeyError:
  516. raise BoxKeyError('{0}'.format(key))
  517. else:
  518. del self[key]
  519. return item
  520. def clear(self):
  521. self._box_config['__ordered_box_values'] = []
  522. super(Box, self).clear()
  523. def popitem(self):
  524. try:
  525. key = next(self.__iter__())
  526. except StopIteration:
  527. raise BoxKeyError('Empty box')
  528. return key, self.pop(key)
  529. def __repr__(self):
  530. return '<Box: {0}>'.format(str(self.to_dict()))
  531. def __str__(self):
  532. return str(self.to_dict())
  533. def __iter__(self):
  534. for key in self.keys():
  535. yield key
  536. def __reversed__(self):
  537. for key in reversed(list(self.keys())):
  538. yield key
  539. def to_dict(self):
  540. """
  541. Turn the Box and sub Boxes back into a native
  542. python dictionary.
  543. :return: python dictionary of this Box
  544. """
  545. out_dict = dict(self)
  546. for k, v in out_dict.items():
  547. if v is self:
  548. out_dict[k] = out_dict
  549. elif hasattr(v, 'to_dict'):
  550. out_dict[k] = v.to_dict()
  551. elif hasattr(v, 'to_list'):
  552. out_dict[k] = v.to_list()
  553. return out_dict
  554. def update(self, item=None, **kwargs):
  555. if not item:
  556. item = kwargs
  557. iter_over = item.items() if hasattr(item, 'items') else item
  558. for k, v in iter_over:
  559. if isinstance(v, dict):
  560. # Box objects must be created in case they are already
  561. # in the `converted` box_config set
  562. v = self.__class__(v)
  563. if k in self and isinstance(self[k], dict):
  564. self[k].update(v)
  565. continue
  566. if isinstance(v, list):
  567. v = BoxList(v)
  568. try:
  569. self.__setattr__(k, v)
  570. except (AttributeError, TypeError):
  571. self.__setitem__(k, v)
  572. def setdefault(self, item, default=None):
  573. if item in self:
  574. return self[item]
  575. if isinstance(default, dict):
  576. default = self.__class__(default)
  577. if isinstance(default, list):
  578. default = BoxList(default)
  579. self[item] = default
  580. return default
  581. def to_json(self, filename=None,
  582. encoding="utf-8", errors="strict", **json_kwargs):
  583. """
  584. Transform the Box object into a JSON string.
  585. :param filename: If provided will save to file
  586. :param encoding: File encoding
  587. :param errors: How to handle encoding errors
  588. :param json_kwargs: additional arguments to pass to json.dump(s)
  589. :return: string of JSON or return of `json.dump`
  590. """
  591. return _to_json(self.to_dict(), filename=filename,
  592. encoding=encoding, errors=errors, **json_kwargs)
  593. @classmethod
  594. def from_json(cls, json_string=None, filename=None,
  595. encoding="utf-8", errors="strict", **kwargs):
  596. """
  597. Transform a json object string into a Box object. If the incoming
  598. json is a list, you must use BoxList.from_json.
  599. :param json_string: string to pass to `json.loads`
  600. :param filename: filename to open and pass to `json.load`
  601. :param encoding: File encoding
  602. :param errors: How to handle encoding errors
  603. :param kwargs: parameters to pass to `Box()` or `json.loads`
  604. :return: Box object from json data
  605. """
  606. bx_args = {}
  607. for arg in kwargs.copy():
  608. if arg in BOX_PARAMETERS:
  609. bx_args[arg] = kwargs.pop(arg)
  610. data = _from_json(json_string, filename=filename,
  611. encoding=encoding, errors=errors, **kwargs)
  612. if not isinstance(data, dict):
  613. raise BoxError('json data not returned as a dictionary, '
  614. 'but rather a {0}'.format(type(data).__name__))
  615. return cls(data, **bx_args)
  616. if yaml_support:
  617. def to_yaml(self, filename=None, default_flow_style=False,
  618. encoding="utf-8", errors="strict",
  619. **yaml_kwargs):
  620. """
  621. Transform the Box object into a YAML string.
  622. :param filename: If provided will save to file
  623. :param default_flow_style: False will recursively dump dicts
  624. :param encoding: File encoding
  625. :param errors: How to handle encoding errors
  626. :param yaml_kwargs: additional arguments to pass to yaml.dump
  627. :return: string of YAML or return of `yaml.dump`
  628. """
  629. return _to_yaml(self.to_dict(), filename=filename,
  630. default_flow_style=default_flow_style,
  631. encoding=encoding, errors=errors, **yaml_kwargs)
  632. @classmethod
  633. def from_yaml(cls, yaml_string=None, filename=None,
  634. encoding="utf-8", errors="strict",
  635. loader=yaml.SafeLoader, **kwargs):
  636. """
  637. Transform a yaml object string into a Box object.
  638. :param yaml_string: string to pass to `yaml.load`
  639. :param filename: filename to open and pass to `yaml.load`
  640. :param encoding: File encoding
  641. :param errors: How to handle encoding errors
  642. :param loader: YAML Loader, defaults to SafeLoader
  643. :param kwargs: parameters to pass to `Box()` or `yaml.load`
  644. :return: Box object from yaml data
  645. """
  646. bx_args = {}
  647. for arg in kwargs.copy():
  648. if arg in BOX_PARAMETERS:
  649. bx_args[arg] = kwargs.pop(arg)
  650. data = _from_yaml(yaml_string=yaml_string, filename=filename,
  651. encoding=encoding, errors=errors,
  652. Loader=loader, **kwargs)
  653. if not isinstance(data, dict):
  654. raise BoxError('yaml data not returned as a dictionary'
  655. 'but rather a {0}'.format(type(data).__name__))
  656. return cls(data, **bx_args)
  657. class BoxList(list):
  658. """
  659. Drop in replacement of list, that converts added objects to Box or BoxList
  660. objects as necessary.
  661. """
  662. def __init__(self, iterable=None, box_class=Box, **box_options):
  663. self.box_class = box_class
  664. self.box_options = box_options
  665. self.box_org_ref = self.box_org_ref = id(iterable) if iterable else 0
  666. if iterable:
  667. for x in iterable:
  668. self.append(x)
  669. if box_options.get('frozen_box'):
  670. def frozen(*args, **kwargs):
  671. raise BoxError('BoxList is frozen')
  672. for method in ['append', 'extend', 'insert', 'pop',
  673. 'remove', 'reverse', 'sort']:
  674. self.__setattr__(method, frozen)
  675. def __delitem__(self, key):
  676. if self.box_options.get('frozen_box'):
  677. raise BoxError('BoxList is frozen')
  678. super(BoxList, self).__delitem__(key)
  679. def __setitem__(self, key, value):
  680. if self.box_options.get('frozen_box'):
  681. raise BoxError('BoxList is frozen')
  682. super(BoxList, self).__setitem__(key, value)
  683. def append(self, p_object):
  684. if isinstance(p_object, dict):
  685. try:
  686. p_object = self.box_class(p_object, **self.box_options)
  687. except AttributeError as err:
  688. if 'box_class' in self.__dict__:
  689. raise err
  690. elif isinstance(p_object, list):
  691. try:
  692. p_object = (self if id(p_object) == self.box_org_ref else
  693. BoxList(p_object))
  694. except AttributeError as err:
  695. if 'box_org_ref' in self.__dict__:
  696. raise err
  697. super(BoxList, self).append(p_object)
  698. def extend(self, iterable):
  699. for item in iterable:
  700. self.append(item)
  701. def insert(self, index, p_object):
  702. if isinstance(p_object, dict):
  703. p_object = self.box_class(p_object, **self.box_options)
  704. elif isinstance(p_object, list):
  705. p_object = (self if id(p_object) == self.box_org_ref else
  706. BoxList(p_object))
  707. super(BoxList, self).insert(index, p_object)
  708. def __repr__(self):
  709. return "<BoxList: {0}>".format(self.to_list())
  710. def __str__(self):
  711. return str(self.to_list())
  712. def __copy__(self):
  713. return BoxList((x for x in self),
  714. self.box_class,
  715. **self.box_options)
  716. def __deepcopy__(self, memodict=None):
  717. out = self.__class__()
  718. memodict = memodict or {}
  719. memodict[id(self)] = out
  720. for k in self:
  721. out.append(copy.deepcopy(k))
  722. return out
  723. def __hash__(self):
  724. if self.box_options.get('frozen_box'):
  725. hashing = 98765
  726. hashing ^= hash(tuple(self))
  727. return hashing
  728. raise TypeError("unhashable type: 'BoxList'")
  729. def to_list(self):
  730. new_list = []
  731. for x in self:
  732. if x is self:
  733. new_list.append(new_list)
  734. elif isinstance(x, Box):
  735. new_list.append(x.to_dict())
  736. elif isinstance(x, BoxList):
  737. new_list.append(x.to_list())
  738. else:
  739. new_list.append(x)
  740. return new_list
  741. def to_json(self, filename=None,
  742. encoding="utf-8", errors="strict",
  743. multiline=False, **json_kwargs):
  744. """
  745. Transform the BoxList object into a JSON string.
  746. :param filename: If provided will save to file
  747. :param encoding: File encoding
  748. :param errors: How to handle encoding errors
  749. :param multiline: Put each item in list onto it's own line
  750. :param json_kwargs: additional arguments to pass to json.dump(s)
  751. :return: string of JSON or return of `json.dump`
  752. """
  753. if filename and multiline:
  754. lines = [_to_json(item, filename=False, encoding=encoding,
  755. errors=errors, **json_kwargs) for item in self]
  756. with open(filename, 'w', encoding=encoding, errors=errors) as f:
  757. f.write("\n".join(lines).decode('utf-8') if
  758. sys.version_info < (3, 0) else "\n".join(lines))
  759. else:
  760. return _to_json(self.to_list(), filename=filename,
  761. encoding=encoding, errors=errors, **json_kwargs)
  762. @classmethod
  763. def from_json(cls, json_string=None, filename=None, encoding="utf-8",
  764. errors="strict", multiline=False, **kwargs):
  765. """
  766. Transform a json object string into a BoxList object. If the incoming
  767. json is a dict, you must use Box.from_json.
  768. :param json_string: string to pass to `json.loads`
  769. :param filename: filename to open and pass to `json.load`
  770. :param encoding: File encoding
  771. :param errors: How to handle encoding errors
  772. :param multiline: One object per line
  773. :param kwargs: parameters to pass to `Box()` or `json.loads`
  774. :return: BoxList object from json data
  775. """
  776. bx_args = {}
  777. for arg in kwargs.copy():
  778. if arg in BOX_PARAMETERS:
  779. bx_args[arg] = kwargs.pop(arg)
  780. data = _from_json(json_string, filename=filename, encoding=encoding,
  781. errors=errors, multiline=multiline, **kwargs)
  782. if not isinstance(data, list):
  783. raise BoxError('json data not returned as a list, '
  784. 'but rather a {0}'.format(type(data).__name__))
  785. return cls(data, **bx_args)
  786. if yaml_support:
  787. def to_yaml(self, filename=None, default_flow_style=False,
  788. encoding="utf-8", errors="strict",
  789. **yaml_kwargs):
  790. """
  791. Transform the BoxList object into a YAML string.
  792. :param filename: If provided will save to file
  793. :param default_flow_style: False will recursively dump dicts
  794. :param encoding: File encoding
  795. :param errors: How to handle encoding errors
  796. :param yaml_kwargs: additional arguments to pass to yaml.dump
  797. :return: string of YAML or return of `yaml.dump`
  798. """
  799. return _to_yaml(self.to_list(), filename=filename,
  800. default_flow_style=default_flow_style,
  801. encoding=encoding, errors=errors, **yaml_kwargs)
  802. @classmethod
  803. def from_yaml(cls, yaml_string=None, filename=None,
  804. encoding="utf-8", errors="strict",
  805. loader=yaml.SafeLoader,
  806. **kwargs):
  807. """
  808. Transform a yaml object string into a BoxList object.
  809. :param yaml_string: string to pass to `yaml.load`
  810. :param filename: filename to open and pass to `yaml.load`
  811. :param encoding: File encoding
  812. :param errors: How to handle encoding errors
  813. :param loader: YAML Loader, defaults to SafeLoader
  814. :param kwargs: parameters to pass to `BoxList()` or `yaml.load`
  815. :return: BoxList object from yaml data
  816. """
  817. bx_args = {}
  818. for arg in kwargs.copy():
  819. if arg in BOX_PARAMETERS:
  820. bx_args[arg] = kwargs.pop(arg)
  821. data = _from_yaml(yaml_string=yaml_string, filename=filename,
  822. encoding=encoding, errors=errors,
  823. Loader=loader, **kwargs)
  824. if not isinstance(data, list):
  825. raise BoxError('yaml data not returned as a list'
  826. 'but rather a {0}'.format(type(data).__name__))
  827. return cls(data, **bx_args)
  828. def box_it_up(self):
  829. for v in self:
  830. if hasattr(v, 'box_it_up') and v is not self:
  831. v.box_it_up()
  832. class ConfigBox(Box):
  833. """
  834. Modified box object to add object transforms.
  835. Allows for build in transforms like:
  836. cns = ConfigBox(my_bool='yes', my_int='5', my_list='5,4,3,3,2')
  837. cns.bool('my_bool') # True
  838. cns.int('my_int') # 5
  839. cns.list('my_list', mod=lambda x: int(x)) # [5, 4, 3, 3, 2]
  840. """
  841. _protected_keys = dir({}) + ['to_dict', 'bool', 'int', 'float',
  842. 'list', 'getboolean', 'to_json', 'to_yaml',
  843. 'getfloat', 'getint',
  844. 'from_json', 'from_yaml']
  845. def __getattr__(self, item):
  846. """Config file keys are stored in lower case, be a little more
  847. loosey goosey"""
  848. try:
  849. return super(ConfigBox, self).__getattr__(item)
  850. except AttributeError:
  851. return super(ConfigBox, self).__getattr__(item.lower())
  852. def __dir__(self):
  853. return super(ConfigBox, self).__dir__() + ['bool', 'int', 'float',
  854. 'list', 'getboolean',
  855. 'getfloat', 'getint']
  856. def bool(self, item, default=None):
  857. """ Return value of key as a boolean
  858. :param item: key of value to transform
  859. :param default: value to return if item does not exist
  860. :return: approximated bool of value
  861. """
  862. try:
  863. item = self.__getattr__(item)
  864. except AttributeError as err:
  865. if default is not None:
  866. return default
  867. raise err
  868. if isinstance(item, (bool, int)):
  869. return bool(item)
  870. if (isinstance(item, str) and
  871. item.lower() in ('n', 'no', 'false', 'f', '0')):
  872. return False
  873. return True if item else False
  874. def int(self, item, default=None):
  875. """ Return value of key as an int
  876. :param item: key of value to transform
  877. :param default: value to return if item does not exist
  878. :return: int of value
  879. """
  880. try:
  881. item = self.__getattr__(item)
  882. except AttributeError as err:
  883. if default is not None:
  884. return default
  885. raise err
  886. return int(item)
  887. def float(self, item, default=None):
  888. """ Return value of key as a float
  889. :param item: key of value to transform
  890. :param default: value to return if item does not exist
  891. :return: float of value
  892. """
  893. try:
  894. item = self.__getattr__(item)
  895. except AttributeError as err:
  896. if default is not None:
  897. return default
  898. raise err
  899. return float(item)
  900. def list(self, item, default=None, spliter=",", strip=True, mod=None):
  901. """ Return value of key as a list
  902. :param item: key of value to transform
  903. :param mod: function to map against list
  904. :param default: value to return if item does not exist
  905. :param spliter: character to split str on
  906. :param strip: clean the list with the `strip`
  907. :return: list of items
  908. """
  909. try:
  910. item = self.__getattr__(item)
  911. except AttributeError as err:
  912. if default is not None:
  913. return default
  914. raise err
  915. if strip:
  916. item = item.lstrip('[').rstrip(']')
  917. out = [x.strip() if strip else x for x in item.split(spliter)]
  918. if mod:
  919. return list(map(mod, out))
  920. return out
  921. # loose configparser compatibility
  922. def getboolean(self, item, default=None):
  923. return self.bool(item, default)
  924. def getint(self, item, default=None):
  925. return self.int(item, default)
  926. def getfloat(self, item, default=None):
  927. return self.float(item, default)
  928. def __repr__(self):
  929. return '<ConfigBox: {0}>'.format(str(self.to_dict()))
  930. class SBox(Box):
  931. """
  932. ShorthandBox (SBox) allows for
  933. property access of `dict` `json` and `yaml`
  934. """
  935. _protected_keys = dir({}) + ['to_dict', 'tree_view', 'to_json', 'to_yaml',
  936. 'json', 'yaml', 'from_yaml', 'from_json',
  937. 'dict']
  938. @property
  939. def dict(self):
  940. return self.to_dict()
  941. @property
  942. def json(self):
  943. return self.to_json()
  944. if yaml_support:
  945. @property
  946. def yaml(self):
  947. return self.to_yaml()
  948. def __repr__(self):
  949. return '<ShorthandBox: {0}>'.format(str(self.to_dict()))