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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572
  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' in sys.modules:
  17. #This should be true since LodelContext init method is called
  18. #for a MULTISITE context handling
  19. globals()['lodelsites'] = sys.modules['lodelsites']
  20. from lodel import buildconf
  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. _, ctx_path = LodelContext.lodelsites_paths()
  40. return os.path.join(os.path.join(ctx_path, site_identifier))
  41. ##@brief Designed to permit dynamic packages creation from the lodel package
  42. #
  43. #The class is added in first position in the sys.metapath variable. Doing this
  44. #we override the earlier steps of the import mechanism.
  45. #
  46. #When called the find_spec method determine wether the imported module is
  47. #a part of a virtual lodel package, else it returns None and the standart
  48. #import mechanism go further.
  49. #If it's a submodule of a virtual lodel package we create a symlink
  50. #to represent the lodel package os the FS and then we make python import
  51. #files from the symlink.
  52. #
  53. #
  54. #@note Current implementation is far from perfection. In fact no deletion
  55. #mechanisms is written and the virtual package cannot be a subpackage of
  56. #the lodel package for the moment...
  57. #@note Current implementation asserts that all plugins are in CWD
  58. #a symlink will be done to create a copy of the plugins folder in
  59. #lodelsites/SITENAME/ folder
  60. class LodelMetaPathFinder(importlib.abc.MetaPathFinder):
  61. ##@brief implements the find_spec method of MetaPathFinder
  62. #
  63. #@param fullname str : module fullname
  64. #@param path str : with be the value of __path__ of the parent package
  65. #@param target module : is a module object that the finder may use to
  66. #make a more educated guess about what spec to return
  67. #@see https://docs.python.org/3/library/importlib.html#importlib.abc.MetaPathFinder
  68. def find_spec(fullname, path, target = None):
  69. if fullname.startswith(CTX_PKG+'.'):
  70. spl = fullname.split('.')
  71. site_identifier = spl[1]
  72. #creating a symlink to represent the lodel site package
  73. mod_path = dir_for_context(site_identifier)
  74. if not os.path.exists(mod_path):
  75. os.mkdir(mod_path)
  76. fd = open(os.path.join(mod_path, '__init__.py'), 'w+')
  77. fd.close()
  78. lodel_pkg_path = os.path.join(mod_path, 'lodel')
  79. if not os.path.exists(lodel_pkg_path):
  80. os.symlink(lodel.__path__[0], lodel_pkg_path, True)
  81. #Cache invalidation after we "created" the new package
  82. #importlib.invalidate_caches()
  83. return None
  84. #def invalidate_caches(): pass
  85. ##@brief Class designed to handle context switching and virtual module
  86. #exposure
  87. #
  88. #The main entrypoint of this class is the expose_module method. A kind of
  89. #equivalent of the various import X [as Y], from X import Y [as Z] etc.
  90. #existing in Python.
  91. #The expose_module method add a preffix to the module fullname in order
  92. #to make it reconizable by the LodelMetaPathfinder::find_spec() method.
  93. #All module names are translated before import. The preffix is set at
  94. #__init__ call in __pkg_name. The resulting name is __pkg_name + fullname
  95. #
  96. #@par examples
  97. #When asking for lodel.leapi.leobject :
  98. #- in MONOSITE resulting module will be lodel.leapi.leobject
  99. #- in MULTISITE resulting module name will be
  100. #lodelsites.SITE_ID.lodel.leapi.leobject
  101. #
  102. #The lodelsites package will be a subdir of buildconf.MULTISITE_CONTEXTDIR
  103. #that will be itself added to sys.path in order to be able to import
  104. #lodelsites
  105. #
  106. #@par Notes about dyncode exposure
  107. #In MULTISITE mode the dyncode will be stored as a python module in
  108. #buildconf.MULTISITE_CONTEXTDIR/SITE_ID/leapi_dyncode.py . The dyncode
  109. #exposale process in MULTISITE mode is simply done by asking LodelContext
  110. #to expose a module named leapi_dyncode. The LodelContext::_translate()
  111. #method is able to produce a correct name for this module.
  112. #In MONOSITE mode the dyncode will be stored as a python module in
  113. #the site directory. In this case the _translate method will do the same
  114. #transformation than for the others modules. But in MONOSITE mode the
  115. #module preffix is empty. Resulting in import leapi_dyncode. This will
  116. #work asserting that cwd in MONOSITE mode is the instance directory.
  117. #
  118. #
  119. #
  120. #@note a dedicated context named LOAD_CTX is used as context for the
  121. #loading process
  122. class LodelContext(object):
  123. ##@brief FLag telling that the context handler is in single context mode
  124. MONOSITE = 1
  125. ##@brief Flag telling that the context manager is in multi context mode
  126. MULTISITE = 2
  127. ##@brief Static property storing current context name
  128. _current = None
  129. ##@brief Stores the context type (single or multiple)
  130. _type = None
  131. ##@brief Stores the contexts
  132. _contexts = None
  133. ##@brief Flag indicating if the classe is initialized
  134. __initialized = False
  135. ##@brief Stores path used by MULTISITE instance
  136. #
  137. #This variable is a tuple with 2 elements (in this order):
  138. #- lodelsites datadir (ex: /var/lodel2/MULTISITE_NAME/datadir/)
  139. #- lodelsites contextdir (ex: /varL/lodel2/MULTISITE_NAME/.ctx/lodelsites)
  140. __lodelsites_paths = None
  141. ##@brief Create a new context
  142. #@see LodelContext.new()
  143. def __init__(self, site_id, instance_path = None):
  144. if site_id is None and self.multisite():
  145. site_id = LOAD_CTX
  146. if self.multisite() and site_id is not LOAD_CTX:
  147. with LodelContext.with_context(None) as ctx:
  148. ctx.expose_modules(globals(), {'lodel.logger': 'logger'})
  149. logger.info("New context instanciation named '%s'" % site_id)
  150. if site_id is None:
  151. self.__id = "MONOSITE"
  152. #Monosite instanciation
  153. if self.multisite():
  154. raise ContextError("Cannot instanciate a context with \
  155. site_id set to None when we are in MULTISITE beahavior")
  156. else:
  157. #More verification can be done here (singleton specs ? )
  158. self.__class__._current = self.__class__._contexts = self
  159. self.__pkg_name = 'lodel'
  160. self.__package = lodel
  161. self.__instance_path = os.getcwd()
  162. return
  163. else:
  164. #Multisite instanciation
  165. if not self.multisite():
  166. raise ContextError("Cannot instanciate a context with a \
  167. site_id when we are in MONOSITE beahvior")
  168. if not self.validate_identifier(site_id):
  169. raise ContextError("Given context name is not a valide identifier \
  170. : '%s'" % site_id)
  171. if site_id in self.__class__._contexts:
  172. raise ContextError(
  173. "A context named '%s' allready exists." % site_id)
  174. self.__id = site_id
  175. self.__pkg_name = '%s.%s' % (CTX_PKG, site_id)
  176. if instance_path is None:
  177. """
  178. raise ContextError("Cannot create a context without an \
  179. instance path")
  180. """
  181. warnings.warn("It can be a really BAD idea to create a \
  182. a context without a path......")
  183. self.__instance_path = None
  184. else:
  185. self.__instance_path = os.path.realpath(instance_path)
  186. #Importing the site package to trigger its creation
  187. self.__package = importlib.import_module(
  188. self.__pkg_name)
  189. self.__class__._contexts[site_id] = self
  190. #Designed to be use by with statement
  191. self.__previous_ctx = None
  192. def __repr__(self):
  193. return '<LodelContext name="%s">' % self.__id
  194. ##@brief Expose a module from the context
  195. #@param globs globals : globals where we have to expose the module
  196. #@param spec tuple : first item is module name, second is the alias
  197. def expose(self, globs, spec):
  198. if len(spec) != 2:
  199. raise ContextError("Invalid argument given. Expected a tuple of \
  200. length == 2 but got : %s" % spec)
  201. module_fullname, exposure_spec = spec
  202. module_fullname = self._translate(module_fullname)
  203. if isinstance(exposure_spec, str):
  204. self._expose_module(globs, module_fullname, exposure_spec)
  205. else:
  206. self._expose_objects(globs, module_fullname, exposure_spec)
  207. ##@brief Return a module from current context
  208. def get_module(self, fullname):
  209. fullname = self._translate(fullname)
  210. module = importlib.import_module(fullname)
  211. return module
  212. ##@brief Delete a site's context
  213. #@param site_id str : the site's name to remove the context
  214. def remove(cls, site_id):
  215. if site_id is None:
  216. if cls._type == cls.MULTISITE:
  217. raise ContextError("Cannot have a context with \
  218. site_id set to None when we are in MULTISITE beahavior")
  219. del cls._contexts
  220. else:
  221. if cls._type == cls.MULTISITE:
  222. if site_id in cls._contexts:
  223. del cls._contexts[site_id]
  224. else:
  225. raise ContextError("No site %s exist" % site_id)
  226. else:
  227. raise ContextError("Cannot have a context with \
  228. site_id set when we are in MONOSITE beahavior")
  229. ##@return identifier for current context
  230. @classmethod
  231. def current_id(cls):
  232. return cls._current.__id
  233. ##@return True if the class is in MULTISITE mode
  234. @classmethod
  235. def multisite(cls):
  236. return cls._type == cls.MULTISITE
  237. ##@brief helper class to use LodeContext with with statement
  238. #@note alias to get method
  239. #@note maybe useless
  240. #@todo delete me
  241. @classmethod
  242. def with_context(cls, target_ctx_id):
  243. return cls.get(target_ctx_id)
  244. ##@brief Set a context as active
  245. #
  246. #This method handle the context switching operations. Some static
  247. #attributes are set at this step.
  248. #@note if not in LOAD_CTX a sys.path update is done
  249. #@warning Inconsistency with lodelsites_datasource, we build again the
  250. #site context dir path using site_id. This information should come
  251. #from only one source
  252. #@param site_id str : site identifier (identify a context)
  253. #@todo unify the generation of the site specific context dir path
  254. @classmethod
  255. def set(cls, site_id):
  256. if cls._type == cls.MONOSITE:
  257. raise ContextError("Context cannot be set in MONOSITE beahvior")
  258. site_id = LOAD_CTX if site_id is None else site_id
  259. if not cls.validate_identifier(site_id):
  260. raise ContextError("Given context name is not a valide identifier \
  261. : '%s'" % site_id)
  262. if site_id not in cls._contexts:
  263. raise ContextError("No context named '%s' found." % site_id)
  264. if cls.current_id() != LOAD_CTX and site_id != LOAD_CTX:
  265. raise ContextError("Not allowed to switch into a site context \
  266. from another site context. You have to switch back to LOAD_CTX before")
  267. wanted_ctx = cls._contexts[site_id]
  268. if hasattr(wanted_ctx, '__instance_path'):
  269. os.chdir(self.__instance_path) #May cause problems and may be obsolete
  270. cls._current = wanted_ctx
  271. return cls._current
  272. ##@brief Getter for contexts
  273. #@param ctx_id str | None | False : if False return the current context
  274. #@return A LodelContext instance
  275. @classmethod
  276. def get(cls, ctx_id = False):
  277. if ctx_id is False:
  278. if cls._current is None:
  279. raise ContextError("No context loaded")
  280. return cls._current
  281. ctx_id = LOAD_CTX if ctx_id is None else ctx_id
  282. if ctx_id not in cls._contexts:
  283. raise ContextError("No context identified by '%s'" % ctx_id)
  284. return cls._contexts[ctx_id]
  285. ##@brief Returns the name of the loaded context
  286. @classmethod
  287. def get_name(cls):
  288. if cls._current is None:
  289. raise ContextError("No context loaded")
  290. return copy.copy(cls._current.__id)
  291. ##@brief Create a new context given a context name and switch in it
  292. #
  293. #@note It's just an alias to the LodelContext.__init__ method
  294. #@param site_id str : context name
  295. #@return the context instance
  296. @classmethod
  297. def new(cls, site_id, instance_path = None):
  298. if site_id is None:
  299. site_id = LOAD_CTX
  300. return cls(site_id, instance_path)
  301. ##@brief Helper function that import and expose specified modules
  302. #
  303. #The specs given is a dict. Each element is indexed by a module
  304. #fullname. Items can be of two types :
  305. #@par Simple import with alias
  306. #In this case items of specs is a string representing the alias name
  307. #for the module we are exposing
  308. #@par from x import i,j,k equivalent
  309. #In this case items are lists of object name to expose as it in globals.
  310. #You can specify an alias by giving a tuple instead of a string as
  311. #list element. In this case the first element of the tuple is the object
  312. #name and the second it's alias in the globals
  313. #
  314. #@todo make the specs format more consitant
  315. #@param cls : bultin params
  316. #@param globs dict : the globals dict of the caller module
  317. #@param specs dict : specs of exposure (see comments of this method)
  318. #@todo implements relative module imports. (maybe by looking for
  319. #"calling" package in globs dict)
  320. @classmethod
  321. def expose_modules(cls, globs, specs):
  322. ctx = cls.get()
  323. for spec in specs.items():
  324. ctx.expose(globs, spec)
  325. ##@brief Return a module from current context
  326. #@param fullname str : module fullname
  327. #@todo check if not globals are set when getting a module ! (if so
  328. #checks all calls to this method to check that this assertion was not
  329. #made)
  330. @classmethod
  331. def module(cls, fullname):
  332. return cls.get().get_module(fullname)
  333. ##@brief Expose leapi_dyncode module
  334. @classmethod
  335. def expose_dyncode(cls, globs, alias = 'leapi_dyncode'):
  336. cls.get().expose_modules(globs, { 'leapi_dyncode': alias })
  337. ##@brief Initialize the context manager
  338. #
  339. #@note Add the LodelMetaPathFinder class to sys.metapath if type is
  340. #LodelContext.MULTISITE
  341. #@note lodelsites package name is hardcoded and has to be
  342. #@param type FLAG : takes value in LodelContext.MONOSITE or
  343. #LodelContext.MULTISITE
  344. @classmethod
  345. def init(cls, type=MONOSITE):
  346. if cls._current is not None:
  347. raise ContextError("Context allready started and used. Enable to \
  348. initialize it anymore")
  349. if type not in ( cls.MONOSITE, cls.MULTISITE):
  350. raise ContextError("Invalid flag given : %s" % type)
  351. cls._type = type
  352. if cls._type == cls.MULTISITE:
  353. #Woot hardcoded stuff with no idea of what it implies :-P
  354. lodelsites_path = os.getcwd() #Same assert in the loader
  355. cls.__lodelsites_paths = (
  356. os.path.join(lodelsites_path, buildconf.MULTISITE_DATADIR),
  357. os.path.join(lodelsites_path,
  358. buildconf.MULTISITE_CONTEXTDIR))
  359. #Now we are able to import lodelsites package
  360. sys.path.append(os.path.dirname(cls.__lodelsites_paths[1]))
  361. if 'lodelsites' not in sys.modules:
  362. import lodelsites
  363. globals()['lodelsites'] = sys.modules['lodelsites']
  364. #End of Woot
  365. cls._contexts = dict()
  366. #Add custom MetaPathFinder allowing implementing custom imports
  367. sys.meta_path = [LodelMetaPathFinder] + sys.meta_path
  368. #Create and set __loader__ context
  369. ctx = cls.new(LOAD_CTX)
  370. #DIRTY enforcing
  371. cls._current = ctx
  372. cls.set(LOAD_CTX)
  373. else:
  374. #Add a single context with no site_id
  375. cls._contexts = cls._current = cls(None)
  376. cls.__initialized = True
  377. ##@return True if the class is initialized
  378. @classmethod
  379. def is_initialized(cls):
  380. return cls.__initialized
  381. ##@brief Return the directory of the package of the current loaded context
  382. @classmethod
  383. def context_dir(cls):
  384. if cls._type == cls.MONOSITE:
  385. return './'
  386. return os.path.join(cls.__lodelsites_paths[1],
  387. cls._current.__id)
  388. ##@brief Validate a context identifier
  389. #@param identifier str : the identifier to validate
  390. #@return true if the name is valide else false
  391. @staticmethod
  392. def validate_identifier(identifier):
  393. if identifier == LOAD_CTX:
  394. return True
  395. return identifier is None or \
  396. re.match(r'^[a-zA-Z0-9][a-zA-Z0-9_]', identifier)
  397. ##@brief Safely expose a module in globals using an alias name
  398. #
  399. #@note designed to implements warning messages or stuff like that
  400. #when doing nasty stuff
  401. #
  402. #@warning Logging stuffs may lead in a performance issue
  403. #
  404. #@todo try to use the logger module instead of warnings
  405. #@param globs globals : the globals where we want to expose our
  406. #module alias
  407. #@param obj object : the object we want to expose
  408. #@param alias str : the alias name for our module
  409. @staticmethod
  410. def safe_exposure(globs, obj, alias):
  411. if alias in globs:
  412. if globs[alias] != obj:
  413. print("Context '%s' : A module exposure leads in globals overwriting for \
  414. key '%s' with a different value : %s != %s" % (LodelContext.get_name(), alias, globs[alias], obj))
  415. """#Uncomment this bloc to display a stack trace for dangerous modules overwriting
  416. print("DEBUG INFOS : ")
  417. import traceback
  418. traceback.print_stack()
  419. """
  420. else:
  421. print("Context '%s' : A module exposure leads in a useless replacement for \
  422. key '%s'" % (LodelContext.get_name(),alias))
  423. globs[alias] = obj
  424. ##@brief Create a context from a path and returns the context name
  425. #@param path str : the path from which we extract a sitename
  426. #@return the site identifier
  427. @classmethod
  428. def from_path(cls, path):
  429. if cls._type != cls.MULTISITE:
  430. raise ContextError("Cannot create a context from a path in \
  431. MONOSITE mode")
  432. site_id = os.path.basename(path.strip('/'))
  433. path = os.path.realpath(path)
  434. if not cls.validate_identifier(site_id):
  435. raise ContextError(
  436. "Unable to create a context named '%s'" % site_id)
  437. cls.new(site_id, path)
  438. return site_id
  439. ##@brief Return a tuple containing lodelsites datadir & contextdir (
  440. #in this order)
  441. @classmethod
  442. def lodelsites_paths(cls):
  443. if cls.__lodelsites_paths is None:
  444. raise ContextError('No paths available')
  445. return copy.copy(cls.__lodelsites_paths)
  446. ##@brief Utility method to expose a module with an alias name in globals
  447. #@param globs globals() : concerned globals dict
  448. #@param fullname str : module fullname
  449. #@param alias str : alias name
  450. @classmethod
  451. def _expose_module(cls, globs, fullname, alias):
  452. module = importlib.import_module(fullname)
  453. cls.safe_exposure(globs, module, alias)
  454. ##@brief Utility mehod to expose objects like in a from x import y,z
  455. #form
  456. #@param globs globals() : dict of globals
  457. #@param fullename str : module fullname
  458. #@param objects list : list of object names to expose
  459. @classmethod
  460. def _expose_objects(cls, globs, fullname, objects):
  461. errors = []
  462. module = importlib.import_module(fullname)
  463. for o_name in objects:
  464. if isinstance(o_name, str):
  465. alias = o_name
  466. else:
  467. o_name, alias = o_name
  468. if not hasattr(module, o_name):
  469. errors.append(o_name)
  470. else:
  471. cls.safe_exposure(globs, getattr(module, o_name), alias)
  472. if len(errors) > 0:
  473. msg = "Module %s does not have any of [%s] as attribute" % (
  474. fullname, ','.join(errors))
  475. raise ImportError(msg)
  476. ##@brief Translate a module fullname to the context equivalent
  477. #
  478. #Two transformation are possible :
  479. #- we are importing a submodule of the lodel package : resulting module
  480. #name will be : self.__pkg_name + module_fullname
  481. #- we are importing the dyncode : resulting module name is :
  482. #self.__pkg_name + dyncode_modulename
  483. #@param module_fullname str : a module fullname
  484. #@return The module name in the current context
  485. def _translate(self, module_fullname):
  486. #This test should be obsolete now
  487. if module_fullname.startswith('lodel') or \
  488. module_fullname.startswith('leapi_dyncode'):
  489. if self.multisite():
  490. return self.__pkg_name +'.'+ module_fullname
  491. else:
  492. return module_fullname
  493. raise ContextModuleError("Given module is not lodel nor dyncode \
  494. or any submodule : '%s'" % module_fullname)
  495. ##@brief Implements the with statement behavior
  496. #@see https://www.python.org/dev/peps/pep-0343/
  497. #@see https://wiki.python.org/moin/WithStatement
  498. def __enter__(self):
  499. if not self.multisite:
  500. warnings.warn("Using LodelContext with with statement in \
  501. MONOSITE mode")
  502. if self.__previous_ctx is not None:
  503. raise ContextError("__enter__ called but a previous context \
  504. is allready registered !!! Bailout")
  505. current = LodelContext.get().__id
  506. if current != self.__id:
  507. #Only switch if necessary
  508. self.__previous_ctx = LodelContext.get().__id
  509. LodelContext.set(self.__id)
  510. return self
  511. ##@brief Implements the with statement behavior
  512. #@see https://www.python.org/dev/peps/pep-0343/
  513. #@see https://wiki.python.org/moin/WithStatement
  514. def __exit__(self, exc_type, exc_val, exc_tb):
  515. prev = self.__previous_ctx
  516. self.__previous_ctx = None
  517. if prev is not None:
  518. #Only restore if needed
  519. LodelContext.set(self.__previous_ctx)