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 18KB

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