You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@bloodhound.apache.org by rj...@apache.org on 2014/01/30 04:46:39 UTC

svn commit: r1562687 - in /bloodhound/trunk/bloodhound_multiproduct: multiproduct/config.py multiproduct/env.py multiproduct/hooks.py tests/config.py tests/env.py

Author: rjollos
Date: Thu Jan 30 03:46:39 2014
New Revision: 1562687

URL: http://svn.apache.org/r1562687
Log:
0.8dev: Changes to the product configuration will take effect immediately. Refs #539.

Patch by Olemis Lang.

Modified:
    bloodhound/trunk/bloodhound_multiproduct/multiproduct/config.py
    bloodhound/trunk/bloodhound_multiproduct/multiproduct/env.py
    bloodhound/trunk/bloodhound_multiproduct/multiproduct/hooks.py
    bloodhound/trunk/bloodhound_multiproduct/tests/config.py
    bloodhound/trunk/bloodhound_multiproduct/tests/env.py

Modified: bloodhound/trunk/bloodhound_multiproduct/multiproduct/config.py
URL: http://svn.apache.org/viewvc/bloodhound/trunk/bloodhound_multiproduct/multiproduct/config.py?rev=1562687&r1=1562686&r2=1562687&view=diff
==============================================================================
--- bloodhound/trunk/bloodhound_multiproduct/multiproduct/config.py (original)
+++ bloodhound/trunk/bloodhound_multiproduct/multiproduct/config.py Thu Jan 30 03:46:39 2014
@@ -25,6 +25,7 @@ import os.path
 from trac.config import Configuration, ConfigurationError, Option, \
         OrderedExtensionsOption, Section, _use_default
 from trac.resource import ResourceNotFound
+from trac.util import create_file
 from trac.util.text import to_unicode
 
 from multiproduct.model import ProductSetting
@@ -37,6 +38,9 @@ class Configuration(Configuration):
     Python Standard Library) but retrieving configuration values 
     from the database.
     """
+
+    CONFIG_LOCK_FILE = 'config.lock'
+
     def __init__(self, env, product, parents=None):
         """Initialize configuration object with an instance of 
         `trac.env.Environment` and product prefix.
@@ -48,6 +52,11 @@ class Configuration(Configuration):
         self.env = env
         self.product = to_unicode(product)
         self._sections = {}
+        self._lastmtime = 0
+        self._lock_path = os.path.join(self.env.path, self.CONFIG_LOCK_FILE)
+        if not os.path.exists(self._lock_path):
+            create_file(self._lock_path)
+        self._orig_parents = parents
         self._setup_parents(parents)
 
     def __getitem__(self, name):
@@ -57,6 +66,10 @@ class Configuration(Configuration):
             self._sections[name] = Section(self, name)
         return self._sections[name]
 
+    def get_lock_file_mtime(self):
+        """Returns to modification time of the lock file."""
+        return os.path.getmtime(self._lock_path)
+
     def sections(self, compmgr=None, defaults=True):
         """Return a list of section names.
 
@@ -87,25 +100,40 @@ class Configuration(Configuration):
         return defaults and (section, option) in Option.registry
 
     def save(self):
-        """Nothing to do.
+        """Just touch config lock file.
 
-        Notice: Opposite to Trac's Configuration objects Bloodhound's
+        Notice: In contrast to Trac's Configuration objects Bloodhound's
         product configuration objects commit changes to the database 
         immediately. Thus there's no much to do in this method.
         """
+        self.touch()
+        self._lastmtime = self.get_lock_file_mtime()
 
     def parse_if_needed(self, force=False):
-        """Just invalidate options cache.
+        """Invalidate options cache considering global lock timestamp.
 
         Notice: Opposite to Trac's Configuration objects Bloodhound's
         product configuration objects commit changes to the database 
         immediately. Thus there's no much to do in this method.
         """
-        for section in self.sections():
-            self[section]._cache.clear()
+        changed = False
+        modtime = self.get_lock_file_mtime()
+        if force or modtime > self._lastmtime:
+            self._sections = {}
+            self._lastmtime = modtime
+            changed = True
+
+        if changed:
+            self._setup_parents(self._orig_parents)
+        else:
+            for parent in self.parents:
+                changed |= parent.parse_if_needed(force=force)
+
+        return changed
 
     def touch(self):
-        pass
+        if os.access(self._lock_path, os.W_OK):
+            os.utime(self._lock_path, None)
 
     def set_defaults(self, compmgr=None):
         """Retrieve all default values and store them explicitly in the

Modified: bloodhound/trunk/bloodhound_multiproduct/multiproduct/env.py
URL: http://svn.apache.org/viewvc/bloodhound/trunk/bloodhound_multiproduct/multiproduct/env.py?rev=1562687&r1=1562686&r2=1562687&view=diff
==============================================================================
--- bloodhound/trunk/bloodhound_multiproduct/multiproduct/env.py (original)
+++ bloodhound/trunk/bloodhound_multiproduct/multiproduct/env.py Thu Jan 30 03:46:39 2014
@@ -323,8 +323,7 @@ class EnvironmentStub(trac.test.Environm
             del env._rules
         except AttributeError:
             pass
-        # FIXME: Shall we ?
-        #env.config.save()
+        env.config.save()
 
     def reset_db(self, default_data=None):
         multiproduct_schema = self._multiproduct_schema_enabled
@@ -634,6 +633,25 @@ class ProductEnvironment(Component, Comp
 
     is_component_enabled_local = trac.env.Environment.is_component_enabled.im_func
 
+    def is_enabled(self, cls):
+        """Return whether the given component class is enabled."""
+        modtime = max(self.config.get_lock_file_mtime(),
+                      self.config._lastmtime)
+        if modtime > self._config_mtime:
+            self.enabled.clear()
+            try:
+                del self._rules
+            except AttributeError:
+                pass
+            # FIXME : Improve cache hits by tracking global env last mtime
+            self.parent.enabled.clear()
+            try:
+                del self.parent._rules
+            except AttributeError:
+                pass
+        self._config_mtime = modtime
+        return super(ProductEnvironment, self).is_enabled(cls)
+
     def is_component_enabled(self, cls):
         """Implemented to only allow activation of components already 
         activated in the global environment that are in turn not disabled in
@@ -806,6 +824,7 @@ class ProductEnvironment(Component, Comp
         else:
             parents = [self.parent.config]
         self.config = Configuration(self.parent, self.product.prefix, parents)
+        self._config_mtime = 0
         self.setup_log()
 
     def setup_log(self):

Modified: bloodhound/trunk/bloodhound_multiproduct/multiproduct/hooks.py
URL: http://svn.apache.org/viewvc/bloodhound/trunk/bloodhound_multiproduct/multiproduct/hooks.py?rev=1562687&r1=1562686&r2=1562687&view=diff
==============================================================================
--- bloodhound/trunk/bloodhound_multiproduct/multiproduct/hooks.py (original)
+++ bloodhound/trunk/bloodhound_multiproduct/multiproduct/hooks.py Thu Jan 30 03:46:39 2014
@@ -70,6 +70,7 @@ class MultiProductEnvironmentFactory(Env
             env = create_product_env(pid,
                                      environ['SCRIPT_NAME'] + '/products/' +
                                      pid, product_path)
+            env.config.parse_if_needed()
 
         return env
 

Modified: bloodhound/trunk/bloodhound_multiproduct/tests/config.py
URL: http://svn.apache.org/viewvc/bloodhound/trunk/bloodhound_multiproduct/tests/config.py?rev=1562687&r1=1562686&r2=1562687&view=diff
==============================================================================
--- bloodhound/trunk/bloodhound_multiproduct/tests/config.py (original)
+++ bloodhound/trunk/bloodhound_multiproduct/tests/config.py Thu Jan 30 03:46:39 2014
@@ -19,12 +19,13 @@
 
 """Tests for Apache(TM) Bloodhound's product configuration objects"""
 
-from ConfigParser import ConfigParser
-from itertools import groupby
 import os.path
 import shutil
-from StringIO import StringIO
+import time
 import unittest
+from ConfigParser import ConfigParser
+from StringIO import StringIO
+from itertools import groupby
 
 from trac.config import Option
 from trac.tests.config import ConfigurationTestCase
@@ -35,9 +36,8 @@ from multiproduct.config import Configur
 from multiproduct.model import Product, ProductSetting
 from tests.env import MultiproductTestCase
 
-class ProductConfigTestCase(ConfigurationTestCase, MultiproductTestCase):
-    r"""Test cases for Trac configuration objects rewritten for product 
-    scope.
+class MultiproductConfigTestCase(MultiproductTestCase):
+    r"""Test setup for configuration test cases.
     """
     def setUp(self):
         r"""Replace Trac environment with product environment
@@ -48,7 +48,7 @@ class ProductConfigTestCase(Configuratio
         tmpdir = os.path.realpath(self.env.path)
         self.filename = os.path.join(tmpdir, 'conf', 'product.ini')
         # Ensure conf sub-folder is created
-        os.mkdir(os.path.dirname(self.filename))
+        os.path.dirname(self.filename)
 
         self._upgrade_mp(self.env)
         self._setup_test_log(self.env)
@@ -131,6 +131,11 @@ class ProductConfigTestCase(Configuratio
                 dump.append('%s = %s\n' % (row[1], row[2]))
         return dump
 
+
+class ProductConfigTestCase(MultiproductConfigTestCase, ConfigurationTestCase):
+    r"""Test cases for Trac configuration objects rewritten for product 
+    scope.
+    """
     # Test cases rewritten to avoid reading config file. 
     # It does make sense for product config as it's stored in the database
 
@@ -196,8 +201,62 @@ class ProductConfigTestCase(Configuratio
         config.set('a', 'option', 'value2')
         self.assertEquals('value2', config.get('a', 'option'))
 
+
+class ProductConfigSyncTestCase(MultiproductConfigTestCase):
+    """Test cases for concurrent access of product configuration objects.
+    """
+    def test_sync(self):
+        """Config cache consistency on concurrent edits
+        """
+        config1 = self._read()
+        config2 = self._read()
+
+        # Initial values will be empty
+        # This will initialize both instances' cache
+        self.assertEqual('', config1.get('s', 'o'))
+        self.assertEqual('', config2.get('s', 'o'))
+
+        # First time assignment, no actual cache
+        config1.set('s', 'o', 'value0')
+        self.assertEqual('value0', config1.get('s', 'o'))
+        self.assertEqual('value0', config2.get('s', 'o'))
+
+        # Subsequent hits retrieved from cache
+        config1.set('s', 'o', 'value1')
+        self.assertEqual('value0', config2.get('s', 'o'))
+        # ... unless cache invalidated e.g. by calling save()
+        config1.save()
+        self.assertTrue(config2.parse_if_needed())
+        self.assertEqual('value1', config1.get('s', 'o'))
+        self.assertEqual('value1', config2.get('s', 'o'))
+
+        # TODO: Replace with trac.util.compat:wait_for_file_mtime_change when
+        # changes from Trac 1.0-stable (> r12258) or Trac 1.0.2 are integrated
+        # Two edits may look simultaneous depending on FS accuracy,
+        # so wait 1 second to ensure next timestamp below will be different
+        # otherwise the test is fragile and results non-deterministic.
+        # This holds for Trac config objects too.
+        time.sleep(1)
+
+        # After update no subsequent modifications reported
+        config2.set('s', 'o', 'value2')
+        self.assertFalse(config1.parse_if_needed())
+        self.assertEqual('value1', config1.get('s', 'o'))
+        # ... unless cache invalidated e.g. by calling touch()
+        config2.touch()
+        self.assertTrue(config1.parse_if_needed())
+        self.assertEqual('value2', config1.get('s', 'o'))
+        self.assertEqual('value2', config2.get('s', 'o'))
+        self.assertTrue(config2.parse_if_needed())
+
+
 def test_suite():
-    return unittest.makeSuite(ProductConfigTestCase,'test')
+    suite = unittest.TestSuite()
+
+    suite.addTest(unittest.makeSuite(ProductConfigTestCase,'test'))
+    suite.addTest(unittest.makeSuite(ProductConfigSyncTestCase,'test'))
+
+    return suite
 
 if __name__ == '__main__':
     unittest.main(defaultTest='test_suite')

Modified: bloodhound/trunk/bloodhound_multiproduct/tests/env.py
URL: http://svn.apache.org/viewvc/bloodhound/trunk/bloodhound_multiproduct/tests/env.py?rev=1562687&r1=1562686&r2=1562687&view=diff
==============================================================================
--- bloodhound/trunk/bloodhound_multiproduct/tests/env.py (original)
+++ bloodhound/trunk/bloodhound_multiproduct/tests/env.py Thu Jan 30 03:46:39 2014
@@ -19,15 +19,17 @@
 
 """Tests for Apache(TM) Bloodhound's product environments"""
 
-from inspect import stack
 import os.path
 import shutil
+import sys
 import tempfile
+from inspect import stack
 from tests import unittest
 from types import MethodType
 
+from trac.admin.api import AdminCommandManager, IAdminCommandProvider
 from trac.config import Option
-from trac.core import Component, ComponentMeta
+from trac.core import Component, ComponentMeta, implements
 from trac.env import Environment
 from trac.test import EnvironmentStub, MockPerm
 from trac.tests.env import EnvironmentTestCase
@@ -164,11 +166,14 @@ class MultiproductTestCase(unittest.Test
         self.env = env = EnvironmentStub(**kwargs)
         if create_folder:
             if path is None:
-                env.path = tempfile.mkdtemp(prefix='bh-product-tempenv-')
+                env.path = tempfile.mkdtemp(prefix='bh-tempenv-')
             else:
                 env.path = path
-                if not os.path.exists(path):
-                    os.mkdir(path)
+                if not os.path.exists(env.path):
+                    os.mkdir(env.path)
+            conf_dir = os.path.join(env.path, 'conf')
+            if not os.path.exists(conf_dir):
+                os.mkdir(conf_dir)
         return env
 
     def _setup_test_log(self, env):
@@ -223,8 +228,8 @@ class MultiproductTestCase(unittest.Test
         r"""Apply multi product upgrades
         """
         # Do not break wiki parser ( see #373 )
-        env.disable_component(TicketModule)
-        env.disable_component(ReportModule)
+        EnvironmentStub.disable_component_in_config(env, TicketModule)
+        EnvironmentStub.disable_component_in_config(env, ReportModule)
 
         mpsystem = MultiProductSystem(env)
         try:
@@ -395,6 +400,7 @@ class ProductEnvApiTestCase(Multiproduct
             pass
         # Let's pretend this was declared elsewhere
         C.__module__ = 'dummy_module'
+        sys.modules['dummy_module'] = sys.modules[__name__]
 
         global_env = self.env
         product_env = self.product_env
@@ -411,6 +417,8 @@ class ProductEnvApiTestCase(Multiproduct
             expected_rules = {
                 'multiproduct': True,
                 'trac': True,
+                'trac.ticket.report.reportmodule': False,
+                'trac.ticket.web_ui.ticketmodule': False,
                 'trac.db': True,
                 cname: False,
             }
@@ -560,8 +568,7 @@ class ProductEnvHrefTestCase(Multiproduc
         return decorator
 
     def setUp(self):
-        self._mp_setup()
-        self.env.path = '/path/to/env'
+        self._mp_setup(create_folder=True)
         self.env.abs_href = Href('http://globalenv.com/trac.cgi')
         url_pattern = getattr(getattr(self, self._testMethodName).im_func,
                               'product_base_url', '')
@@ -570,6 +577,7 @@ class ProductEnvHrefTestCase(Multiproduc
         self.product_env = ProductEnvironment(self.env, self.default_product)
 
     def tearDown(self):
+        shutil.rmtree(os.path.dirname(self.env.path), ignore_errors=True)
         # Release reference to transient environment mock object
         if self.env is not None:
             try:
@@ -675,11 +683,139 @@ class ProductEnvHrefTestCase(Multiproduc
     product_base_url = staticmethod(product_base_url)
 
 
+class ProductEnvConfigTestCase(MultiproductTestCase):
+    """Test cases for product environment's configuration
+    """
+
+    class DummyAdminCommand(Component):
+        """Dummy class used for testing purposes
+        """
+        implements(IAdminCommandProvider) 
+
+        class DummyException(Exception):
+            pass
+
+        def do_fail(self, *args):
+            raise DummyException(args)
+
+        def get_admin_commands(self):
+            yield "fail", "[ARG]...", "Always fail", None, self.do_fail
+
+
+    def setUp(self):
+        self._mp_setup(create_folder=True)
+        self.global_env = self.env
+        self.env = ProductEnvironment(self.global_env, self.default_product)
+
+        # Random component class
+        self.component_class = self.DummyAdminCommand
+
+    def tearDown(self):
+        if self.global_env is not None:
+            try:
+                self.global_env.reset_db()
+            except self.global_env.db_exc.OperationalError:
+                # "Database not found ...",
+                # "OperationalError: no such table: system" or the like
+                pass
+
+        shutil.rmtree(self.env.path)
+        self.env = self.global_env = None
+
+    def test_regression_bh_539(self):
+        tracadmin = AdminCommandManager(self.env)
+
+        self.assertTrue(self.env[self.component_class] is None,
+                        "Expected component disabled")
+        self.assertFalse(any(isinstance(c, self.component_class)
+                             for c in tracadmin.providers),
+                         "Component erroneously listed in admin cmd providers")
+        self.assertEqual([], tracadmin.get_command_help(args=['fail']))
+
+        # Enable component in both global and product context
+        cmd_args = ['config', 'set', 'components', __name__ + '.*', 'enabled']
+        AdminCommandManager(self.global_env).execute_command(*cmd_args)
+        tracadmin.execute_command(*cmd_args)
+
+        self.assertTrue(self.env[self.component_class] is not None,
+                        "Expected component enabled")
+        self.assertTrue(any(isinstance(c, self.component_class)
+                            for c in tracadmin.providers),
+                        "Component not listed in admin cmd providers")
+        self.assertEqual(1, len(tracadmin.get_command_help(args=['fail'])))
+
+    def test_regression_bh_539_concurrent(self):
+        try:
+            # It is necessary to load another environment object to work around
+            # ProductEnvironment class' parametric singleton constraint
+            old_env = self.env 
+            # In-memory DB has to be shared
+            self.global_env.__class__.global_databasemanager = \
+                self.env.global_databasemanager
+            new_global_env = self._setup_test_env(create_folder=True,
+                                                  path=self.global_env.path)
+            self.env = old_env
+            self._setup_test_log(new_global_env)
+            
+            # FIXME: EnvironmentStub config is not bound to a real file
+            # ... so let's reuse one config for both envs to simulate that they
+            # are in sync, a condition verified in another test case
+            new_global_env.config = self.global_env.config
+            
+            new_env = ProductEnvironment(new_global_env, self.default_product)
+
+            self.assertTrue(new_global_env is not self.global_env)
+            self.assertTrue(new_env is not self.env)
+            self.assertEqual(self.env.path, new_env.path)
+            self.assertEqual(self.env.config._lock_path,
+                             new_env.config._lock_path)
+            
+            tracadmin = AdminCommandManager(self.env)
+            new_tracadmin = AdminCommandManager(new_env)
+    
+            # Assertions for self.env
+            self.assertTrue(self.env[self.component_class] is None,
+                            "Expected component disabled")
+            self.assertFalse(any(isinstance(c, self.component_class)
+                                 for c in tracadmin.providers),
+                             "Component erroneously listed in admin cmd "
+                             "providers")
+            self.assertEqual([], tracadmin.get_command_help(args=['fail']))
+    
+            # Repeat assertions for new_env
+            self.assertTrue(new_env[self.component_class] is None,
+                            "Expected component disabled")
+            self.assertFalse(any(isinstance(c, self.component_class)
+                                 for c in new_tracadmin.providers),
+                             "Component erroneously listed in admin cmd "
+                             "providers")
+            self.assertEqual([], new_tracadmin.get_command_help(args=['fail']))
+    
+            # Enable component in both self.global_env and self.env contexts
+            cmd_args = ['config', 'set', 'components',
+                       __name__ + '.*', 'enabled']
+            AdminCommandManager(self.global_env).execute_command(*cmd_args)
+            tracadmin.execute_command(*cmd_args)
+    
+            # Assert that changes are auto-magically reflected in new_env
+            self.assertTrue(new_env[self.component_class] is not None,
+                            "Expected component enabled")
+            self.assertTrue(any(isinstance(c, self.component_class)
+                                for c in new_tracadmin.providers),
+                            "Component not listed in admin cmd providers")
+            self.assertEqual(
+                1, len(new_tracadmin.get_command_help(args=['fail'])))
+        finally:
+            self.global_env.__class__.global_databasemanager = None
+            new_global_env = new_env = None
+
+
 def test_suite():
     return unittest.TestSuite([
         unittest.makeSuite(ProductEnvTestCase, 'test'),
         unittest.makeSuite(ProductEnvApiTestCase, 'test'),
         unittest.makeSuite(ProductEnvHrefTestCase, 'test'),
+        unittest.makeSuite(ProductEnvConfigTestCase, 'test'),
     ])
 
 if __name__ == '__main__':