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

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