No Description
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

settings.py 13KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347
  1. #
  2. # This file is part of Lodel 2 (https://github.com/OpenEdition)
  3. #
  4. # Copyright (C) 2015-2017 Cléo UMS-3287
  5. #
  6. # This program is free software: you can redistribute it and/or modify
  7. # it under the terms of the GNU Affero General Public License as published
  8. # by the Free Software Foundation, either version 3 of the License, or
  9. # (at your option) any later version.
  10. #
  11. # This program is distributed in the hope that it will be useful,
  12. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  14. # GNU Affero General Public License for more details.
  15. #
  16. # You should have received a copy of the GNU Affero General Public License
  17. # along with this program. If not, see <http://www.gnu.org/licenses/>.
  18. #
  19. import sys
  20. import os
  21. import configparser
  22. import copy
  23. import warnings
  24. import types # for dynamic bindings
  25. from collections import namedtuple
  26. from lodel.context import LodelContext
  27. LodelContext.expose_modules(globals(), {
  28. 'lodel.logger': 'logger',
  29. 'lodel.settings.utils': ['SettingsError', 'SettingsErrors'],
  30. 'lodel.validator.validator': ['Validator', 'LODEL2_CONF_SPECS',
  31. 'confspec_append'],
  32. 'lodel.settings.settings_loader': ['SettingsLoader']})
  33. ##  @package lodel.settings.settings Lodel2 settings module
  34. #
  35. # Contains the class that handles the namedtuple tree of settings
  36. ## @brief A default python system lib path
  37. PYTHON_SYS_LIB_PATH = '/usr/local/lib/python{major}.{minor}/'.format(
  38. major=sys.version_info.major,
  39. minor=sys.version_info.minor)
  40. class MetaSettings(type):
  41. @property
  42. def s(self):
  43. self.singleton_assert(True)
  44. return self.instance.settings
  45. ## @brief Handles configuration load etc.
  46. #
  47. # To see howto bootstrap Settings and use it in lodel instance see
  48. # @ref lodel.settings
  49. #
  50. # @par Basic instance usage
  51. # For example if a file defines confs like :
  52. # <pre>
  53. # [super_section]
  54. # super_conf = super_value
  55. # </pre>
  56. # You can access it with :
  57. # <pre> settings_instance.confs.super_section.super_conf </pre>
  58. #
  59. # @par Init sequence
  60. # The initialization sequence is a bit tricky. In fact, plugins adds allowed
  61. # configuration sections/values, but the list of plugins to load are in... the
  62. # settings.
  63. # Here is the conceptual presentation of Settings class initialization stages :
  64. # -# Preloading (sets values like lodel2 library path or the plugins path)
  65. # -# Ask a @ref lodel.settings.setting_loader.SettingsLoader to load all
  66. # configurations files
  67. # -# Fetch the list of plugins in the loaded settings
  68. # -# Merge plugins settings specification with the global lodel settings
  69. # specs ( see @ref lodel.plugin )
  70. # -# Fetch all settings from the merged settings specs
  71. #
  72. # @par Init sequence in practical
  73. # In practice those steps are done by calling a succession of private methods :
  74. # -# @ref Settings.__bootstrap() ( steps 1 to 3 )
  75. # -# @ref Settings.__merge_specs() ( step 4 )
  76. # -# @ref Settings.__populate_from_specs() (step 5)
  77. # -# And finally @ref Settings.__confs_to_namedtuple()
  78. #
  79. # @todo handles default sections for variable sections (sections ending with
  80. # '.*')
  81. # @todo delete the first stage, the lib path HAVE TO BE HARDCODED. In fact
  82. # when we will run lodel in production the lodel2 lib will be in the python path
  83. #@todo add log messages (now we can)
  84. class Settings(object, metaclass=MetaSettings):
  85. ## @brief Stores the singleton instance
  86. instance = None
  87. ## @brief Instanciate the Settings singleton
  88. # @param conf_dir str : The configuration directory
  89. #@param custom_confspecs None | dict : if given overwrite default lodel2
  90. # confspecs
  91. def __init__(self, conf_dir, custom_confspecs=None):
  92. self.singleton_assert() # check that it is the only instance
  93. Settings.instance = self
  94. #  @brief Configuration specification
  95. #
  96. # Initialized by Settings.__bootstrap() method
  97. self.__conf_specs = custom_confspecs
  98. #  @brief Stores the configurations in namedtuple tree
  99. self.__confs = None
  100. self.__conf_dir = conf_dir
  101. self.__started = False
  102. self.__bootstrap()
  103. ## @brief Get the named tuple representing configuration
  104. @property
  105. def settings(self):
  106. return self.__confs.lodel2
  107. ## @brief Delete the singleton instance
  108. @classmethod
  109. def stop(cls):
  110. del(cls.instance)
  111. cls.instance = None
  112. @classmethod
  113. def started(cls):
  114. return cls.instance is not None and cls.instance.__started
  115. ## @brief An utility method that raises if the singleton is not in a good
  116. # state
  117. #@param expect_instanciated bool : if True we expect that the class is
  118. # allready instanciated, else not
  119. # @throw RuntimeError
  120. @classmethod
  121. def singleton_assert(cls, expect_instanciated=False):
  122. if expect_instanciated:
  123. if not cls.started():
  124. raise RuntimeError("The Settings class is not started yet")
  125. else:
  126. if cls.started():
  127. raise RuntimeError("The Settings class is already started")
  128. ## @brief Saves a new configuration for section confname
  129. #@param confname is the name of the modified section
  130. #@param confvalue is a dict with variables to save
  131. #@param validator is a dict with adapted validator
  132. @classmethod
  133. def set(cls, confname, confvalue, validator):
  134. loader = SettingsLoader(cls.instance.__conf_dir)
  135. confkey = confname.rpartition('.')
  136. loader.setoption(confkey[0], confkey[2], confvalue, validator)
  137. # @brief This method handles Settings instance bootstraping
  138. def __bootstrap(self):
  139. LodelContext.expose_modules(globals(), {
  140. 'lodel.plugin.plugins': ['Plugin', 'PluginError']})
  141. logger.debug("Settings bootstraping")
  142. if self.__conf_specs is None:
  143. lodel2_specs = LODEL2_CONF_SPECS
  144. else:
  145. lodel2_specs = self.__conf_specs
  146. self.__conf_specs = None
  147. loader = SettingsLoader(self.__conf_dir)
  148. plugin_list = []
  149. for ptype_name, ptype in Plugin.plugin_types().items():
  150. pls = ptype.plist_confspecs()
  151. lodel2_specs = confspec_append(lodel2_specs, **pls)
  152. cur_list = loader.getoption(
  153. pls['section'],
  154. pls['key'],
  155. pls['validator'],
  156. pls['default'])
  157. if cur_list is None:
  158. continue
  159. try:
  160. if isinstance(cur_list, str):
  161. cur_list = [cur_list]
  162. plugin_list += cur_list
  163. except TypeError:
  164. plugin_list += [cur_list]
  165. # Remove invalid plugin names
  166. plugin_list = [plugin for plugin in plugin_list if len(plugin) > 0]
  167. # Checking confspecs
  168. for section in lodel2_specs:
  169. if section.lower() != section:
  170. raise SettingsError(
  171. "Only lower case are allowed in section name (thank's ConfigParser...)")
  172. for kname in lodel2_specs[section]:
  173. if kname.lower() != kname:
  174. raise SettingsError(
  175. "Only lower case are allowed in section name (thank's ConfigParser...)")
  176. # Starting the Plugins class
  177. logger.debug("Starting lodel.plugin.Plugin class")
  178. Plugin.start(plugin_list)
  179. # Fetching conf specs from plugins
  180. specs = [lodel2_specs]
  181. errors = list()
  182. for plugin_name in plugin_list:
  183. try:
  184. specs.append(Plugin.get(plugin_name).confspecs)
  185. except PluginError as e:
  186. errors.append(SettingsError(msg=str(e)))
  187. if len(errors) > 0: # Raise all plugins import errors
  188. raise SettingsErrors(errors)
  189. self.__conf_specs = self.__merge_specs(specs)
  190. self.__populate_from_specs(self.__conf_specs, loader)
  191. self.__started = True
  192. ## @brief Produce a configuration specification dict by merging all specifications
  193. #
  194. # Merges global lodel2 conf spec from @ref lodel.settings.validator.LODEL2_CONF_SPECS
  195. # and configuration specifications from loaded plugins
  196. # @param specs list : list of specifications dict
  197. # @return a specification dict
  198. def __merge_specs(self, specs):
  199. res = copy.copy(specs.pop())
  200. for spec in specs:
  201. for section in spec:
  202. if section.lower() != section:
  203. raise SettingsError(
  204. "Only lower case are allowed in section name (thank's ConfigParser...)")
  205. if section not in res:
  206. res[section] = dict()
  207. for kname in spec[section]:
  208. if kname.lower() != kname:
  209. raise SettingsError(
  210. "Only lower case are allowed in section name (thank's ConfigParser...)")
  211. if kname in res[section]:
  212. raise SettingsError("Duplicated key '%s' in section '%s'" %
  213. (kname, section))
  214. res[section.lower()][kname] = copy.copy(spec[section][kname])
  215. return res
  216. ## @brief Populate the Settings instance with options values fetched with the loader from merged specs
  217. #
  218. # Populate the __confs attribute
  219. # @param specs dict : Settings specification dictionnary as returned by __merge_specs
  220. # @param loader SettingsLoader : A SettingsLoader instance
  221. def __populate_from_specs(self, specs, loader):
  222. self.__confs = dict()
  223. specs = copy.copy(specs) # Avoid destroying original specs dict (may be useless)
  224. # Construct final specs dict replacing variable sections
  225. # by the actual existing sections
  226. variable_sections = [section for section in specs if section.endswith('.*')]
  227. for vsec in variable_sections:
  228. preffix = vsec[:-2]
  229. # WARNING : hardcoded default section
  230. for section in loader.getsection(preffix, 'default'):
  231. specs[section] = copy.copy(specs[vsec])
  232. del(specs[vsec])
  233. # Fetching values for sections
  234. for section in specs:
  235. for kname in specs[section]:
  236. validator = specs[section][kname][1]
  237. default = specs[section][kname][0]
  238. if section not in self.__confs:
  239. self.__confs[section] = dict()
  240. self.__confs[section][kname] = loader.getoption(section, kname, validator, default)
  241. # Checking unfectched values
  242. loader.raise_errors()
  243. self.__confs_to_namedtuple()
  244. pass
  245. ## @brief Transform the __confs attribute into imbricated namedtuple
  246. #
  247. # For example an option named "foo" in a section named "hello.world" will
  248. # be acessible with self.__confs.hello.world.foo
  249. def __confs_to_namedtuple(self):
  250. res = None
  251. end = False
  252. splits = list()
  253. for section in self.__confs:
  254. splits.append(section.split('.'))
  255. max_len = max([len(spl) for spl in splits])
  256. # building a tree from sections splits
  257. section_tree = dict()
  258. for spl in splits:
  259. section_name = ""
  260. cur = section_tree
  261. for sec_part in spl:
  262. section_name += sec_part + '.'
  263. if sec_part not in cur:
  264. cur[sec_part] = dict()
  265. cur = cur[sec_part]
  266. section_name = section_name[:-1]
  267. for kname, kval in self.__confs[section_name].items():
  268. if kname in cur:
  269. raise SettingsError("Duplicated key for '%s.%s'" % (section_name, kname))
  270. cur[kname] = kval
  271. path = [('root', section_tree)]
  272. visited = set()
  273. curname = 'root'
  274. nodename = 'Lodel2Settings'
  275. cur = section_tree
  276. while True:
  277. visited.add(nodename)
  278. left = [(kname, cur[kname])
  279. for kname in cur
  280. if nodename + '.' + kname.title() not in visited and isinstance(cur[kname], dict)
  281. ]
  282. if len(left) == 0:
  283. name, leaf = path.pop()
  284. typename = nodename.replace('.', '')
  285. if len(path) == 0:
  286. # END
  287. self.__confs = self.__tree2namedtuple(leaf, typename)
  288. break
  289. else:
  290. path[-1][1][name] = self.__tree2namedtuple(leaf, typename)
  291. nodename = '.'.join(nodename.split('.')[:-1])
  292. cur = path[-1][1]
  293. else:
  294. curname, cur = left[0]
  295. path.append((curname, cur))
  296. nodename += '.' + curname.title()
  297. ## @brief Forge a named tuple given a conftree node
  298. # @param conftree dict : A conftree node
  299. # @param name str
  300. # @return a named tuple with fieldnames corresponding to conftree keys
  301. def __tree2namedtuple(self, conftree, name):
  302. ResNamedTuple = namedtuple(name, conftree.keys())
  303. return ResNamedTuple(**conftree)
  304. class MetaSettingsRO(type):
  305. def __getattr__(self, name):
  306. return getattr(Settings.s, name)
  307. ## @brief A class that provide . notation read only access to configurations
  308. class SettingsRO(object, metaclass=MetaSettingsRO):
  309. pass