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.

context.py 19KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491
  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 importlib
  20. import importlib.machinery
  21. import importlib.abc
  22. import sys
  23. import types
  24. import os
  25. import os.path
  26. import re
  27. import copy
  28. import warnings #For the moment no way to use the logger in this file (I guess)
  29. #A try to avoid circular dependencies problems
  30. if 'lodel' not in sys.modules:
  31. import lodel
  32. else:
  33. globals()['lodel'] = sys.modules['lodel']
  34. if 'lodelsites' not in sys.modules:
  35. import lodelsites
  36. else:
  37. globals()['lodelsites'] = sys.modules['lodelsites']
  38. ##@brief Name of the package that will contains all the virtual lodel
  39. #packages
  40. CTX_PKG = "lodelsites"
  41. ##@brief Reserved context name for loading steps
  42. #@note This context is designed to be set at loading time, allowing lodel2
  43. #main process to use lodel packages
  44. LOAD_CTX = "__loader__"
  45. #
  46. # Following exception classes are written here to avoid circular dependencies
  47. # problems.
  48. #
  49. ##@brief Designed to be raised by the context manager
  50. class ContextError(Exception):
  51. pass
  52. ##@brief Raised when an error concerning context modules occurs
  53. class ContextModuleError(ContextError):
  54. pass
  55. def dir_for_context(site_identifier):
  56. return os.path.join(lodelsites.__path__[0], site_identifier)
  57. ##@brief Designed to permit dynamic packages creation from the lodel package
  58. #
  59. #The class is added in first position in the sys.metapath variable. Doing this
  60. #we override the earlier steps of the import mechanism.
  61. #
  62. #When called the find_spec method determine wether the imported module is
  63. #a part of a virtual lodel package, else it returns None and the standart
  64. #import mechanism go further.
  65. #If it's a submodule of a virtual lodel package we create a symlink
  66. #to represent the lodel package os the FS and then we make python import
  67. #files from the symlink.
  68. #
  69. #@note Current implementation is far from perfection. In fact no deletion
  70. #mechanisms is written and the virtual package cannot be a subpackage of
  71. #the lodel package for the moment...
  72. #@note Current implementation asserts that all plugins are in CWD
  73. #a symlink will be done to create a copy of the plugins folder in
  74. #lodelsites/SITENAME/ folder
  75. class LodelMetaPathFinder(importlib.abc.MetaPathFinder):
  76. def find_spec(fullname, path, target = None):
  77. if fullname.startswith(CTX_PKG):
  78. spl = fullname.split('.')
  79. site_identifier = spl[1]
  80. #creating a symlink to represent the lodel site package
  81. mod_path = dir_for_context(site_identifier)
  82. if not os.path.exists(mod_path):
  83. os.symlink(lodel.__path__[0], mod_path, True)
  84. #Cache invalidation after we "created" the new package
  85. #importlib.invalidate_caches()
  86. return None
  87. ##@brief Class designed to handle context switching and virtual module
  88. #exposure
  89. #
  90. #@note a dedicated context named LOAD_CTX is used as context for the
  91. #loading process
  92. class LodelContext(object):
  93. ##@brief FLag telling that the context handler is in single context mode
  94. MONOSITE = 1
  95. ##@brief Flag telling that the context manager is in multi context mode
  96. MULTISITE = 2
  97. ##@brief Static property storing current context name
  98. _current = None
  99. ##@brief Stores the context type (single or multiple)
  100. _type = None
  101. ##@brief Stores the contexts
  102. _contexts = None
  103. ##@brief Flag indicating if the classe is initialized
  104. __initialized = False
  105. ##@brief Create a new context
  106. #@see LodelContext.new()
  107. def __init__(self, site_id, instance_path = None):
  108. if site_id is None and self.multisite():
  109. site_id = LOAD_CTX
  110. if self.multisite() and site_id is not LOAD_CTX:
  111. with LodelContext.with_context(None) as ctx:
  112. ctx.expose_modules(globals(), {'lodel.logger': 'logger'})
  113. logger.info("New context instanciation named '%s'" % site_id)
  114. if site_id is None:
  115. self.__id = "MONOSITE"
  116. #Monosite instanciation
  117. if self.multisite():
  118. raise ContextError("Cannot instanciate a context with \
  119. site_id set to None when we are in MULTISITE beahavior")
  120. else:
  121. #More verification can be done here (singleton specs ? )
  122. self.__class__._current = self.__class__._contexts = self
  123. self.__pkg_name = 'lodel'
  124. self.__package = lodel
  125. self.__instance_path = os.getcwd()
  126. return
  127. else:
  128. #Multisite instanciation
  129. if not self.multisite():
  130. raise ContextError("Cannot instanciate a context with a \
  131. site_id when we are in MONOSITE beahvior")
  132. if not self.validate_identifier(site_id):
  133. raise ContextError("Given context name is not a valide identifier \
  134. : '%s'" % site_id)
  135. if site_id in self.__class__._contexts:
  136. raise ContextError(
  137. "A context named '%s' allready exists." % site_id)
  138. self.__id = site_id
  139. self.__pkg_name = '%s.%s' % (CTX_PKG, site_id)
  140. if instance_path is None:
  141. """
  142. raise ContextError("Cannot create a context without an \
  143. instance path")
  144. """
  145. warnings.warn("It can be a really BAD idea to create a \
  146. a context without a path......")
  147. self.__instance_path = None
  148. else:
  149. self.__instance_path = os.path.realpath(instance_path)
  150. #Importing the site package to trigger its creation
  151. self.__package = importlib.import_module(self.__pkg_name)
  152. self.__class__._contexts[site_id] = self
  153. #Designed to be use by with statement
  154. self.__previous_ctx = None
  155. ##@brief Expose a module from the context
  156. #@param globs globals : globals where we have to expose the module
  157. #@param spec tuple : first item is module name, second is the alias
  158. def expose(self, globs, spec):
  159. if len(spec) != 2:
  160. raise ContextError("Invalid argument given. Expected a tuple of \
  161. length == 2 but got : %s" % spec)
  162. module_fullname, exposure_spec = spec
  163. module_fullname = self._translate(module_fullname)
  164. if isinstance(exposure_spec, str):
  165. self._expose_module(globs, module_fullname, exposure_spec)
  166. else:
  167. self._expose_objects(globs, module_fullname, exposure_spec)
  168. ##@brief Return a module from current context
  169. def get_module(self, fullname):
  170. fullname = self._translate(fullname)
  171. module = importlib.import_module(fullname)
  172. return module
  173. ##@brief Delete a site's context
  174. #@param site_id str : the site's name to remove the context
  175. def remove(cls, site_id):
  176. if site_id is None:
  177. if cls._type == cls.MULTISITE:
  178. raise ContextError("Cannot have a context with \
  179. site_id set to None when we are in MULTISITE beahavior")
  180. del cls._contexts
  181. else:
  182. if cls._type == cls.MULTISITE:
  183. if site_id in cls._contexts:
  184. del cls._contexts[site_id]
  185. else:
  186. raise ContextError("No site %s exist" % site_id)
  187. else:
  188. raise ContextError("Cannot have a context with \
  189. site_id set when we are in MONOSITE beahavior")
  190. ##@return True if the class is in MULTISITE mode
  191. @classmethod
  192. def multisite(cls):
  193. return cls._type == cls.MULTISITE
  194. ##@brief helper class to use LodeContext with with statement
  195. #@note alias to get method
  196. #@note maybe useless
  197. #@todo delete me
  198. @classmethod
  199. def with_context(cls, target_ctx_id):
  200. return cls.get(target_ctx_id)
  201. ##@brief Set a context as active
  202. #@param site_id str : site identifier (identify a context)
  203. @classmethod
  204. def set(cls, site_id):
  205. if cls._type == cls.MONOSITE:
  206. raise ContextError("Context cannot be set in MONOSITE beahvior")
  207. site_id = LOAD_CTX if site_id is None else site_id
  208. if not cls.validate_identifier(site_id):
  209. raise ContextError("Given context name is not a valide identifier \
  210. : '%s'" % site_id)
  211. if site_id not in cls._contexts:
  212. raise ContextError("No context named '%s' found." % site_id)
  213. wanted_ctx = cls._contexts[site_id]
  214. if hasattr(wanted_ctx, '__instance_path'):
  215. os.chdir(self.__instance_path) #May cause problems
  216. cls._current = wanted_ctx
  217. return cls._current
  218. ##@brief Getter for contexts
  219. #@param ctx_id str | None | False : if False return the current context
  220. #@return A LodelContext instance
  221. @classmethod
  222. def get(cls, ctx_id = False):
  223. if ctx_id is False:
  224. if cls._current is None:
  225. raise ContextError("No context loaded")
  226. return cls._current
  227. ctx_id = LOAD_CTX if ctx_id is None else ctx_id
  228. if ctx_id not in cls._contexts:
  229. raise ContextError("No context identified by '%s'" % ctx_id)
  230. return cls._contexts[ctx_id]
  231. ##@brief Returns the name of the loaded context
  232. @classmethod
  233. def get_name(cls):
  234. if cls._current is None:
  235. raise ContextError("No context loaded")
  236. return copy.copy(cls._current.__id)
  237. ##@brief Create a new context given a context name
  238. #
  239. #@note It's just an alias to the LodelContext.__init__ method
  240. #@param site_id str : context name
  241. #@return the context instance
  242. @classmethod
  243. def new(cls, site_id, instance_path = None):
  244. if site_id is None:
  245. site_id = LOAD_CTX
  246. return cls(site_id, instance_path)
  247. ##@brief Helper function that import and expose specified modules
  248. #
  249. #The specs given is a dict. Each element is indexed by a module
  250. #fullname. Items can be of two types :
  251. #@par Simple import with alias
  252. #In this case items of specs is a string representing the alias name
  253. #for the module we are exposing
  254. #@par from x import i,j,k equivalent
  255. #In this case items are lists of object name to expose as it in globals.
  256. #You can specify an alias by giving a tuple instead of a string as
  257. #list element. In this case the first element of the tuple is the object
  258. #name and the second it's alias in the globals
  259. #
  260. #@todo make the specs format more consitant
  261. #@param cls : bultin params
  262. #@param globs dict : the globals dict of the caller module
  263. #@param specs dict : specs of exposure (see comments of this method)
  264. #@todo implements relative module imports. (maybe by looking for
  265. #"calling" package in globs dict)
  266. @classmethod
  267. def expose_modules(cls, globs, specs):
  268. ctx = cls.get()
  269. for spec in specs.items():
  270. ctx.expose(globs, spec)
  271. ##@brief Return a module from current context
  272. #@param fullname str : module fullname
  273. @classmethod
  274. def module(cls, fullname):
  275. return cls.get().get_module(fullname)
  276. ##@brief Expose leapi_dyncode module
  277. @classmethod
  278. def expose_dyncode(cls, globs, alias = 'leapi_dyncode'):
  279. cls.get()._expose_dyncode(globs, alias)
  280. ##@brief Initialize the context manager
  281. #
  282. #@note Add the LodelMetaPathFinder class to sys.metapath if type is
  283. #LodelContext.MULTISITE
  284. #@param type FLAG : takes value in LodelContext.MONOSITE or
  285. #LodelContext.MULTISITE
  286. @classmethod
  287. def init(cls, type=MONOSITE):
  288. if cls._current is not None:
  289. raise ContextError("Context allready started and used. Enable to \
  290. initialize it anymore")
  291. if type not in ( cls.MONOSITE, cls.MULTISITE):
  292. raise ContextError("Invalid flag given : %s" % type)
  293. cls._type = type
  294. if cls._type == cls.MULTISITE:
  295. cls._contexts = dict()
  296. #Add custom MetaPathFinder allowing implementing custom imports
  297. sys.meta_path = [LodelMetaPathFinder] + sys.meta_path
  298. #Create and set __loader__ context
  299. cls.new(LOAD_CTX)
  300. cls.set(LOAD_CTX)
  301. else:
  302. #Add a single context with no site_id
  303. cls._contexts = cls._current = cls(None)
  304. cls.__initialized = True
  305. ##@return True if the class is initialized
  306. @classmethod
  307. def is_initialized(cls):
  308. return cls.__initialized
  309. ##@brief Return the directory of the package of the current loaded context
  310. @classmethod
  311. def context_dir(cls):
  312. if cls._type == cls.MONOSITE:
  313. return './'
  314. return dir_for_context(cls._current.__id)
  315. ##@brief Validate a context identifier
  316. #@param identifier str : the identifier to validate
  317. #@return true if the name is valide else false
  318. @staticmethod
  319. def validate_identifier(identifier):
  320. if identifier == LOAD_CTX:
  321. return True
  322. return identifier is None or \
  323. re.match(r'^[a-zA-Z0-9][a-zA-Z0-9_]', identifier)
  324. ##@brief Safely expose a module in globals using an alias name
  325. #
  326. #@note designed to implements warning messages or stuff like that
  327. #when doing nasty stuff
  328. #
  329. #@warning Logging stuffs may lead in a performance issue
  330. #
  331. #@todo try to use the logger module instead of warnings
  332. #@param globs globals : the globals where we want to expose our
  333. #module alias
  334. #@param obj object : the object we want to expose
  335. #@param alias str : the alias name for our module
  336. @staticmethod
  337. def safe_exposure(globs, obj, alias):
  338. if alias in globs:
  339. if globs[alias] != obj:
  340. print("Context '%s' : A module exposure leads in globals overwriting for \
  341. key '%s' with a different value : %s != %s" % (LodelContext.get_name(), alias, globs[alias], obj))
  342. """#Uncomment this bloc to display a stack trace for dangerous modules overwriting
  343. print("DEBUG INFOS : ")
  344. import traceback
  345. traceback.print_stack()
  346. """
  347. else:
  348. print("Context '%s' : A module exposure leads in a useless replacement for \
  349. key '%s'" % (LodelContext.get_name(),alias))
  350. globs[alias] = obj
  351. ##@brief Create a context from a path and returns the context name
  352. #@param path str : the path from which we extract a sitename
  353. #@return the site identifier
  354. @classmethod
  355. def from_path(cls, path):
  356. if cls._type != cls.MULTISITE:
  357. raise ContextError("Cannot create a context from a path in \
  358. MONOSITE mode")
  359. site_id = os.path.basename(path.strip('/'))
  360. path = os.path.realpath(path)
  361. if not cls.validate_identifier(site_id):
  362. raise ContextError(
  363. "Unable to create a context named '%s'" % site_id)
  364. cls.new(site_id, path)
  365. return site_id
  366. ##@brief Utility method to expose a module with an alias name in globals
  367. #@param globs globals() : concerned globals dict
  368. #@param fullname str : module fullname
  369. #@param alias str : alias name
  370. @classmethod
  371. def _expose_module(cls, globs, fullname, alias):
  372. module = importlib.import_module(fullname)
  373. cls.safe_exposure(globs, module, alias)
  374. ##@brief Utility mehod to expose objects like in a from x import y,z
  375. #form
  376. #@param globs globals() : dict of globals
  377. #@param fullename str : module fullname
  378. #@param objects list : list of object names to expose
  379. @classmethod
  380. def _expose_objects(cls, globs, fullname, objects):
  381. errors = []
  382. module = importlib.import_module(fullname)
  383. for o_name in objects:
  384. if isinstance(o_name, str):
  385. alias = o_name
  386. else:
  387. o_name, alias = o_name
  388. if not hasattr(module, o_name):
  389. errors.append(o_name)
  390. else:
  391. cls.safe_exposure(globs, getattr(module, o_name), alias)
  392. if len(errors) > 0:
  393. msg = "Module %s does not have any of [%s] as attribute" % (
  394. fullname, ','.join(errors))
  395. raise ImportError(msg)
  396. ##@brief Implements LodelContext::expose_dyncode()
  397. #@todo change hardcoded leapi_dyncode.py filename
  398. def _expose_dyncode(self, globs, alias = 'leapi_dyncode'):
  399. fullname = '%s.%s.dyncode' % (CTX_PKG, self.__id)
  400. if fullname in sys.modules:
  401. dyncode = sys.modules[fullname]
  402. else:
  403. path = os.path.join(self.__instance_path, 'leapi_dyncode.py')
  404. sfl = importlib.machinery.SourceFileLoader(fullname, path)
  405. dyncode = sfl.load_module()
  406. self.safe_exposure(globs, dyncode, alias)
  407. ##@brief Translate a module fullname to the context equivalent
  408. #@param module_fullname str : a module fullname
  409. #@return The module name in the current context
  410. def _translate(self, module_fullname):
  411. if not module_fullname.startswith('lodel'):
  412. raise ContextModuleError("Given module is not lodel or any \
  413. submodule : '%s'" % module_fullname)
  414. return module_fullname.replace('lodel', self.__pkg_name)
  415. ##@brief Implements the with statement behavior
  416. #@see https://www.python.org/dev/peps/pep-0343/
  417. #@see https://wiki.python.org/moin/WithStatement
  418. def __enter__(self):
  419. if not self.multisite:
  420. warnings.warn("Using LodelContext with with statement in \
  421. MONOSITE mode")
  422. if self.__previous_ctx is not None:
  423. raise ContextError("__enter__ called but a previous context \
  424. is allready registered !!! Bailout")
  425. current = LodelContext.get().__id
  426. if current != self.__id:
  427. #Only switch if necessary
  428. self.__previous_ctx = LodelContext.get().__id
  429. LodelContext.set(self.__id)
  430. return self
  431. ##@brief Implements the with statement behavior
  432. #@see https://www.python.org/dev/peps/pep-0343/
  433. #@see https://wiki.python.org/moin/WithStatement
  434. def __exit__(self, exc_type, exc_val, exc_tb):
  435. prev = self.__previous_ctx
  436. self.__previous_ctx = None
  437. if prev is not None:
  438. #Only restore if needed
  439. LodelContext.set(self.__previous_ctx)