Olemis Lang avatar Olemis Lang committed 05a393c

BH Multiproduct #115 : Rebase product envs (config) patches against r1429886

Comments (0)

Files changed (7)

-t333/t333_r1427886_schema_autoinc.diff
-t333/t333_r1427886_mp_gen_schema.diff
-t115/t115_r1427886_product_envs.diff
-t115/t115_r1427886_product_config.diff
-t115/t115_r1423431_product_envs_testing.diff
+#t333/t333_r1427886_schema_autoinc.diff
+#t333/t333_r1427886_mp_gen_schema.diff
+t115/t115_r1429886_product_envs.diff
+t115/t115_r1429886_product_config.diff
+t115/t115_r1429886_product_envs_testing.diff

t115/t115_r1423431_product_envs_testing.diff

-# HG changeset patch
-# Parent 3ff6f332771a5ea5c61370fcf58e51f3e520969f
-BH Theme #115 : Test cases for product environments
-
-diff -r 3ff6f332771a bloodhound_multiproduct/tests/config.py
---- /dev/null	Thu Jan 01 00:00:00 1970 +0000
-+++ b/bloodhound_multiproduct/tests/config.py	Tue Jan 08 01:46:58 2013 -0500
-@@ -0,0 +1,197 @@
-+# -*- coding: utf-8 -*-
-+#
-+#  Licensed to the Apache Software Foundation (ASF) under one
-+#  or more contributor license agreements.  See the NOTICE file
-+#  distributed with this work for additional information
-+#  regarding copyright ownership.  The ASF licenses this file
-+#  to you under the Apache License, Version 2.0 (the
-+#  "License"); you may not use this file except in compliance
-+#  with the License.  You may obtain a copy of the License at
-+#
-+#   http://www.apache.org/licenses/LICENSE-2.0
-+#
-+#  Unless required by applicable law or agreed to in writing,
-+#  software distributed under the License is distributed on an
-+#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-+#  KIND, either express or implied.  See the License for the
-+#  specific language governing permissions and limitations
-+#  under the License.
-+
-+"""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 unittest
-+
-+from trac.config import Option
-+from trac.tests.config import ConfigurationTestCase
-+from trac.util.text import to_unicode
-+
-+from multiproduct.api import MultiProductSystem
-+from multiproduct.config import Configuration
-+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.
-+    """
-+    def setUp(self):
-+        r"""Replace Trac environment with product environment
-+        """
-+        self.env = self._setup_test_env()
-+
-+        # Dummy config file, a sibling of trac.ini
-+        self.filename = os.path.join(self.env.path, 'conf', 'product.ini')
-+        # Ensure conf sub-folder is created
-+        os.mkdir(os.path.dirname(self.filename))
-+
-+        self._upgrade_mp(self.env)
-+        self._setup_test_log(self.env)
-+        self._load_product_from_data(self.env, self.default_product)
-+        self._orig_registry = Option.registry
-+        Option.registry = {}
-+
-+    def tearDown(self):
-+        Option.registry = self._orig_registry
-+        shutil.rmtree(self.env.path)
-+        self.env = None
-+
-+    def _read(self, parents=None, product=None):
-+        r"""Override superclass method by returning product-aware configuration
-+        object retrieving settings from the database. Such objects will replace
-+        instances of `trac.config.Configuration` used in inherited test cases.
-+        """
-+        if product is None:
-+            product = self.default_product
-+        return Configuration(self.env, product, parents)
-+
-+    def _write(self, lines, product=None):
-+        r"""Override superclass method by writing configuration values
-+        to the database rather than ini file in the filesystem.
-+        """
-+        if product is None:
-+            product = self.default_product
-+        product = to_unicode(product)
-+        fp = StringIO(('\n'.join(lines + [''])).encode('utf-8'))
-+        parser = ConfigParser()
-+        parser.readfp(fp, 'bh-product-test')
-+        with self.env.db_transaction as db:
-+            # Delete existing setting for target product , if any
-+            for setting in ProductSetting.select(self.env, db, 
-+                    {'product' : product}):
-+                setting.delete()
-+            # Insert new options
-+            for section in parser.sections():
-+                option_key = dict(
-+                        section=to_unicode(section), 
-+                        product=to_unicode(product)
-+                    )
-+                for option, value in parser.items(section):
-+                    option_key.update(dict(option=to_unicode(option)))
-+                    setting = ProductSetting(self.env)
-+                    setting._data.update(option_key)
-+                    setting._data['value'] = to_unicode(value)
-+                    setting.insert()
-+
-+    def _test_with_inherit(self, testcb):
-+        """Almost exact copy of `trac.tests.config.ConfigurationTestCase`.
-+        Differences explained in inline comments.
-+        """
-+        # Parent configuration file created in environment's conf sub-folder
-+        # PS: This modification would not be necessary if the corresponding
-+        #     statement in overriden method would be written the same way
-+        #     but the fact that both files have the same parent folder
-+        #     is not made obvious in there
-+        sitename = os.path.join(os.path.dirname(self.filename), 'trac-site.ini')
-+
-+        try:
-+            with open(sitename, 'w') as sitefile:
-+                sitefile.write('[a]\noption = x\n')
-+
-+            self._write(['[inherit]', 'file = trac-site.ini'])
-+            testcb()
-+        finally:
-+            os.remove(sitename)
-+
-+    def _dump_settings(self, config):
-+        product = config.product
-+        fields = ('section', 'option', 'value')
-+        rows = [tuple(getattr(s, f, None) for f in fields) for s in 
-+                ProductSetting.select(config.env, where={'product' : product})]
-+
-+        dump = []
-+        for section, group in groupby(sorted(rows), lambda row: row[0]):
-+            dump.append('[%s]\n' % (section,))
-+            for row in group:
-+                dump.append('%s = %s\n' % (row[1], row[2]))
-+        return dump
-+
-+    # Test cases rewritten to avoid reading config file. 
-+    # It does make sense for product config as it's stored in the database
-+
-+    def test_set_and_save(self):
-+        config = self._read()
-+        config.set('b', u'öption0', 'y')
-+        config.set(u'aä', 'öption0', 'x')
-+        config.set('aä', 'option2', "Voilà l'été")  # UTF-8
-+        config.set(u'aä', 'option1', u"Voilà l'été") # unicode
-+        # Note: the following would depend on the locale.getpreferredencoding()
-+        # config.set('a', 'option3', "Voil\xe0 l'\xe9t\xe9") # latin-1
-+        self.assertEquals('x', config.get(u'aä', u'öption0'))
-+        self.assertEquals(u"Voilà l'été", config.get(u'aä', 'option1'))
-+        self.assertEquals(u"Voilà l'été", config.get(u'aä', 'option2'))
-+        config.save()
-+
-+        dump = self._dump_settings(config)
-+        self.assertEquals([
-+                           u'[aä]\n',
-+                           u"option1 = Voilà l'été\n", 
-+                           u"option2 = Voilà l'été\n", 
-+                           u'öption0 = x\n', 
-+                           # u"option3 = Voilà l'été\n", 
-+                           u'[b]\n',
-+                           u'öption0 = y\n', 
-+                           ],
-+                          dump)
-+        config2 = self._read()
-+        self.assertEquals('x', config2.get(u'aä', u'öption0'))
-+        self.assertEquals(u"Voilà l'été", config2.get(u'aä', 'option1'))
-+        self.assertEquals(u"Voilà l'été", config2.get(u'aä', 'option2'))
-+        # self.assertEquals(u"Voilà l'été", config2.get('a', 'option3'))
-+
-+    def test_set_and_save_inherit(self):
-+        def testcb():
-+            config = self._read()
-+            config.set('a', 'option2', "Voilà l'été")  # UTF-8
-+            config.set('a', 'option1', u"Voilà l'été") # unicode
-+            self.assertEquals('x', config.get('a', 'option'))
-+            self.assertEquals(u"Voilà l'été", config.get('a', 'option1'))
-+            self.assertEquals(u"Voilà l'été", config.get('a', 'option2'))
-+            config.save()
-+
-+            dump = self._dump_settings(config)
-+            self.assertEquals([
-+                               u'[a]\n',
-+                               u"option1 = Voilà l'été\n", 
-+                               u"option2 = Voilà l'été\n", 
-+                               u'[inherit]\n',
-+                               u"file = trac-site.ini\n", 
-+                               ],
-+                              dump)
-+            config2 = self._read()
-+            self.assertEquals('x', config2.get('a', 'option'))
-+            self.assertEquals(u"Voilà l'été", config2.get('a', 'option1'))
-+            self.assertEquals(u"Voilà l'été", config2.get('a', 'option2'))
-+        self._test_with_inherit(testcb)
-+
-+
-+def suite():
-+    return unittest.makeSuite(ProductConfigTestCase,'test')
-+
-+if __name__ == '__main__':
-+    unittest.main(defaultTest='suite')
-+
-diff -r 3ff6f332771a bloodhound_multiproduct/tests/env.py
---- /dev/null	Thu Jan 01 00:00:00 1970 +0000
-+++ b/bloodhound_multiproduct/tests/env.py	Tue Jan 08 01:46:58 2013 -0500
-@@ -0,0 +1,139 @@
-+
-+#  Licensed to the Apache Software Foundation (ASF) under one
-+#  or more contributor license agreements.  See the NOTICE file
-+#  distributed with this work for additional information
-+#  regarding copyright ownership.  The ASF licenses this file
-+#  to you under the Apache License, Version 2.0 (the
-+#  "License"); you may not use this file except in compliance
-+#  with the License.  You may obtain a copy of the License at
-+#
-+#   http://www.apache.org/licenses/LICENSE-2.0
-+#
-+#  Unless required by applicable law or agreed to in writing,
-+#  software distributed under the License is distributed on an
-+#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-+#  KIND, either express or implied.  See the License for the
-+#  specific language governing permissions and limitations
-+#  under the License.
-+
-+"""Tests for Apache(TM) Bloodhound's product environments"""
-+
-+import os.path
-+import shutil
-+import tempfile
-+import unittest
-+
-+from trac.test import EnvironmentStub
-+from trac.tests.env import EnvironmentTestCase
-+
-+from multiproduct.api import MultiProductSystem
-+from multiproduct.env import ProductEnvironment
-+from multiproduct.model import Product
-+
-+# FIXME: Subclass TestCase explictly ?
-+class MultiproductTestCase(unittest.TestCase):
-+    r"""Mixin providing access to multi-product testing extensions.
-+
-+    This class serves to the purpose of upgrading existing Trac test cases
-+    with multi-product super-powers while still providing the foundations
-+    to create product-specific subclasses.
-+    """
-+
-+    # Product data
-+
-+    default_product = 'tp1'
-+    MAX_TEST_PRODUCT = 3
-+
-+    PRODUCT_DATA = dict(
-+            ['tp' + str(i), {'prefix':'tp' + str(i),
-+                             'name' : 'test product ' + str(i),
-+                             'description' : 'desc for tp' + str(i)}]
-+            for i in xrange(1, MAX_TEST_PRODUCT)
-+        )
-+
-+    # Test setup
-+
-+    def _setup_test_env(self, create_folder=True, path=None):
-+        r"""Prepare a new test environment . 
-+
-+        Optionally set its path to a meaningful location (temp folder
-+        if `path` is `None`).
-+        """
-+        self.env = env = EnvironmentStub(enable=['trac.*', 'multiproduct.*'])
-+        if create_folder:
-+            if path is None:
-+                env.path = tempfile.mkdtemp('bh-product-tempenv')
-+            else:
-+                env.path = path
-+        return env
-+
-+    def _setup_test_log(self, env):
-+        r"""Ensure test product with prefix is loaded
-+        """
-+        logdir = tempfile.gettempdir()
-+        logpath = os.path.join(logdir, 'trac-testing.log')
-+        config = env.config
-+        config.set('logging', 'log_file', logpath)
-+        config.set('logging', 'log_type', 'file')
-+        config.set('logging', 'log_level', 'DEBUG')
-+        config.save()
-+        env.setup_log()
-+        env.log.info('%s test case: %s %s',
-+                '-' * 10, self.id(), '-' * 10)
-+
-+    def _load_product_from_data(self, env, prefix):
-+        r"""Ensure test product with prefix is loaded
-+        """
-+        # TODO: Use fixtures implemented in #314
-+        product_data = self.PRODUCT_DATA[prefix]
-+        product = Product(env)
-+        product._data.update(product_data)
-+        product.insert()
-+
-+    def _upgrade_mp(self, env):
-+        r"""Apply multi product upgrades
-+        """
-+        self.mpsystem = MultiProductSystem(env)
-+        try:
-+            self.mpsystem.upgrade_environment(env.db_transaction)
-+        except OperationalError:
-+            # table remains but database version is deleted
-+            pass
-+
-+class ProductEnvTestCase(EnvironmentTestCase, MultiproductTestCase):
-+    r"""Test cases for Trac environments rewritten for product environments
-+    """
-+
-+    # Test setup
-+
-+    def setUp(self):
-+        r"""Replace Trac environment with product environment
-+        """
-+        EnvironmentTestCase.setUp(self)
-+        try:
-+            self.global_env = self.env
-+            self._setup_test_log(self.global_env)
-+            self._upgrade_mp(self.global_env)
-+            self._load_product_from_data(self.global_env, self.default_product)
-+            try:
-+                self.env = ProductEnvironment(self.global_env, self.default_product)
-+            except :
-+                # All tests should fail if anything goes wrong
-+                self.global_env.log.exception('Error creating product environment')
-+                self.env = None
-+        except:
-+            shutil.rmtree(self.env.path)
-+            raise
-+
-+    def tearDown(self):
-+        # Discard product environment
-+        self.env = self.global_env
-+
-+        EnvironmentTestCase.tearDown(self)
-+
-+def suite():
-+    return unittest.makeSuite(ProductEnvTestCase,'test')
-+
-+if __name__ == '__main__':
-+    unittest.main(defaultTest='suite')
-+

t115/t115_r1427886_product_config.diff

-# HG changeset patch
-# Parent 9fce1096e2a7545deea716178b420928a32358c6
-BH Multiproduct #115 : Product-specific settings
-
-diff -r 9fce1096e2a7 bloodhound_dashboard/bhdashboard/model.py
---- a/bloodhound_dashboard/bhdashboard/model.py	Tue Jan 08 00:31:17 2013 -0500
-+++ b/bloodhound_dashboard/bhdashboard/model.py	Tue Jan 08 00:50:02 2013 -0500
-@@ -117,8 +117,9 @@
-                 self._update_from_row(row)
-                 break
-             else:
--                raise ResourceNotFound('No %(object_name)s with %(where)s' %
--                                sdata)
-+                raise ResourceNotFound(
-+                        ('No %(object_name)s with %(where)s' % sdata) 
-+                                % tuple(values))
-     
-     def delete(self):
-         """Deletes the matching record from the database"""
-@@ -144,14 +145,15 @@
-                                       for k in self._meta['key_fields']]))):
-             sdata = {'keys':','.join(["%s='%s'" % (k, self._data[k])
-                                      for k in self._meta['key_fields']])}
--        elif len(self.select(self._env, where =
-+        elif self._meta['unique_fields'] and len(self.select(self._env, where =
-                                 dict([(k,self._data[k])
-                                       for k in self._meta['unique_fields']]))):
-             sdata = {'keys':','.join(["%s='%s'" % (k, self._data[k])
-                                      for k in self._meta['unique_fields']])}
-         if sdata:
-             sdata.update(self._meta)
--            raise TracError('%(object_name)s %(keys)s already exists' %
-+            sdata['values'] = self._data
-+            raise TracError('%(object_name)s %(keys)s already exists %(values)s' %
-                             sdata)
-             
-         for key in self._meta['key_fields']:
-@@ -207,7 +209,7 @@
-             TicketSystem(self._env).reset_ticket_fields()
-     
-     @classmethod
--    def select(cls, env, db=None, where=None):
-+    def select(cls, env, db=None, where=None, limit=None):
-         """Query the database to get a set of records back"""
-         rows = []
-         fields = cls._meta['key_fields']+cls._meta['non_key_fields']
-@@ -218,7 +220,11 @@
-         wherestr, values = dict_to_kv_str(where)
-         if wherestr:
-             wherestr = ' WHERE ' + wherestr
--        for row in env.db_query(sql + wherestr, values):
-+        if limit is not None:
-+            limitstr = ' LIMIT ' + str(int(limit))
-+        else:
-+            limitstr = ''
-+        for row in env.db_query(sql + wherestr + limitstr, values):
-             # we won't know which class we need until called
-             model = cls.__new__(cls)
-             data = dict([(fields[i], row[i]) for i in range(len(fields))])
-diff -r 9fce1096e2a7 bloodhound_multiproduct/multiproduct/api.py
---- a/bloodhound_multiproduct/multiproduct/api.py	Tue Jan 08 00:31:17 2013 -0500
-+++ b/bloodhound_multiproduct/multiproduct/api.py	Tue Jan 08 00:50:02 2013 -0500
-@@ -31,9 +31,9 @@
- from trac.util.translation import _, N_
- from trac.web.chrome import ITemplateProvider
- 
--from multiproduct.model import Product, ProductResourceMap
-+from multiproduct.model import Product, ProductResourceMap, ProductSetting
- 
--DB_VERSION = 2
-+DB_VERSION = 3
- DB_SYSTEM_KEY = 'bloodhound_multi_product_version'
- PLUGIN_NAME = 'Bloodhound multi product'
- 
-@@ -44,7 +44,7 @@
-             IPermissionRequestor, ITicketFieldProvider, IResourceManager)
-     
-     SCHEMA = [modelcls._get_schema() \
--                for modelcls in (Product, ProductResourceMap)]
-+                for modelcls in (Product, ProductResourceMap, ProductSetting)]
-     del modelcls
-     
-     def get_version(self):
-diff -r 9fce1096e2a7 bloodhound_multiproduct/multiproduct/config.py
---- /dev/null	Thu Jan 01 00:00:00 1970 +0000
-+++ b/bloodhound_multiproduct/multiproduct/config.py	Tue Jan 08 00:50:02 2013 -0500
-@@ -0,0 +1,311 @@
-+
-+#  Licensed to the Apache Software Foundation (ASF) under one
-+#  or more contributor license agreements.  See the NOTICE file
-+#  distributed with this work for additional information
-+#  regarding copyright ownership.  The ASF licenses this file
-+#  to you under the Apache License, Version 2.0 (the
-+#  "License"); you may not use this file except in compliance
-+#  with the License.  You may obtain a copy of the License at
-+#
-+#   http://www.apache.org/licenses/LICENSE-2.0
-+#
-+#  Unless required by applicable law or agreed to in writing,
-+#  software distributed under the License is distributed on an
-+#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-+#  KIND, either express or implied.  See the License for the
-+#  specific language governing permissions and limitations
-+#  under the License.
-+
-+"""Configuration objects for Bloodhound product environments"""
-+
-+__all__ = 'Configuration', 'Section'
-+
-+import os.path
-+
-+from trac.config import Configuration, ConfigurationError, Option, Section, \
-+        _use_default
-+from trac.resource import ResourceNotFound
-+from trac.util.text import to_unicode
-+
-+from multiproduct.model import ProductSetting
-+
-+class Configuration(Configuration):
-+    """Product-aware settings repository equivalent to instances of
-+    `trac.config.Configuration` (and thus `ConfigParser` from the
-+    Python Standard Library) but retrieving configuration values 
-+    from the database.
-+    """
-+    def __init__(self, env, product, parents=None):
-+        """Initialize configuration object with an instance of 
-+        `trac.env.Environment` and product prefix.
-+
-+        Optionally it is possible to inherit settings from parent
-+        Configuration objects. Environment's configuration will not
-+        be added to parents list.
-+        """
-+        self.env = env
-+        self.product = to_unicode(product)
-+        self._sections = {}
-+        self._setup_parents(parents)
-+
-+    def __getitem__(self, name):
-+        """Return the configuration section with the specified name.
-+        """
-+        if name not in self._sections:
-+            self._sections[name] = Section(self, name)
-+        return self._sections[name]
-+
-+    def sections(self, compmgr=None, defaults=True):
-+        """Return a list of section names.
-+
-+        If `compmgr` is specified, only the section names corresponding to
-+        options declared in components that are enabled in the given
-+        `ComponentManager` are returned.
-+        """
-+        sections = set(to_unicode(s) \
-+                for s in ProductSetting.get_sections(self.env, self.product))
-+        for parent in self.parents:
-+            sections.update(parent.sections(compmgr, defaults=False))
-+        if defaults:
-+            sections.update(self.defaults(compmgr))
-+        return sorted(sections)
-+
-+    def has_option(self, section, option, defaults=True):
-+        """Returns True if option exists in section in either the project
-+        trac.ini or one of the parents, or is available through the Option
-+        registry.
-+
-+        (since Trac 0.11)
-+        """
-+        if ProductSetting.exists(self.env, self.product, section, option):
-+            return True
-+        for parent in self.parents:
-+            if parent.has_option(section, option, defaults=False):
-+                return True
-+        return defaults and (section, option) in Option.registry
-+
-+    def save(self):
-+        """Nothing to do.
-+
-+        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.
-+        """
-+
-+    def parse_if_needed(self, force=False):
-+        """Just invalidate options cache.
-+
-+        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()
-+
-+    def touch(self):
-+        pass
-+
-+    def set_defaults(self, compmgr=None):
-+        """Retrieve all default values and store them explicitly in the
-+        configuration, so that they can be saved to file.
-+
-+        Values already set in the configuration are not overridden.
-+        """
-+        for section, default_options in self.defaults(compmgr).items():
-+            for name, value in default_options.items():
-+                if not ProductSetting.exists(self.env, self.product,
-+                        section, name):
-+                    if any(parent[section].contains(name, defaults=False)
-+                           for parent in self.parents):
-+                        value = None
-+                    self.set(section, name, value)
-+
-+    # Helper methods
-+
-+    def _setup_parents(self, parents=None):
-+        """Inherit configuration from parent `Configuration` instances.
-+        If there's a value set to 'file' option in 'inherit' section then
-+        it will be considered as a list of paths to .ini files
-+        that will be added to parents list as well.
-+        """
-+        from trac import config
-+        self.parents = (parents or [])
-+        for filename in self.get('inherit', 'file').split(','):
-+            filename = Section._normalize_path(filename.strip(), self.env)
-+            self.parents.append(config.Configuration(filename))
-+
-+class Section(Section):
-+    """Proxy for a specific configuration section.
-+
-+    Objects of this class should not be instantiated directly.
-+    """
-+    __slots__ = ['config', 'name', 'overridden', '_cache']
-+
-+    def __init__(self, config, name):
-+        self.config = config
-+        self.name = to_unicode(name)
-+        self.overridden = {}
-+        self._cache = {}
-+
-+    @property
-+    def env(self):
-+        return self.config.env
-+
-+    @property
-+    def product(self):
-+        return self.config.product
-+
-+    def contains(self, key, defaults=True):
-+        key = to_unicode(key)
-+        if ProductSetting.exists(self.env, self.product, self.name, key):
-+            return True
-+        for parent in self.config.parents:
-+            if parent[self.name].contains(key, defaults=False):
-+                return True
-+        return defaults and Option.registry.has_key((self.name, key))
-+
-+    __contains__ = contains
-+
-+    def iterate(self, compmgr=None, defaults=True):
-+        """Iterate over the options in this section.
-+
-+        If `compmgr` is specified, only return default option values for
-+        components that are enabled in the given `ComponentManager`.
-+        """
-+        options = set()
-+        name_str = self.name
-+        for setting in ProductSetting.select(self.env,
-+                where={'product':self.product, 'section':name_str}):
-+            option = to_unicode(setting.option)
-+            options.add(option.lower())
-+            yield option
-+        for parent in self.config.parents:
-+            for option in parent[self.name].iterate(defaults=False):
-+                loption = option.lower()
-+                if loption not in options:
-+                    options.add(loption)
-+                    yield option
-+        if defaults:
-+            for section, option in Option.get_registry(compmgr).keys():
-+                if section == self.name and option.lower() not in options:
-+                    yield option
-+
-+    __iter__ = iterate
-+
-+    def __repr__(self):
-+        return '<%s [%s , %s]>' % (self.__class__.__name__, \
-+                self.product, self.name)
-+
-+    def get(self, key, default=''):
-+        """Return the value of the specified option.
-+
-+        Valid default input is a string. Returns a string.
-+        """
-+        cached = self._cache.get(key, _use_default)
-+        if cached is not _use_default:
-+            return cached
-+        name_str = self.name
-+        key_str = to_unicode(key)
-+        settings = ProductSetting.select(self.env, 
-+                where={'product':self.product, 'section':name_str,
-+                        'option':key_str})
-+        if len(settings) > 0:
-+            value = settings[0].value
-+        else:
-+            for parent in self.config.parents:
-+                value = parent[self.name].get(key, _use_default)
-+                if value is not _use_default:
-+                    break
-+            else:
-+                if default is not _use_default:
-+                    option = Option.registry.get((self.name, key))
-+                    value = option.default if option else _use_default
-+                else:
-+                    value = _use_default
-+        if value is _use_default:
-+            return default
-+        if not value:
-+            value = u''
-+        elif isinstance(value, basestring):
-+            value = to_unicode(value)
-+        self._cache[key] = value
-+        return value
-+
-+    def getpath(self, key, default=''):
-+        """Return a configuration value as an absolute path.
-+
-+        Relative paths are resolved relative to `conf` subfolder 
-+        of the target global environment. This approach is consistent
-+        with TracIni path resolution.
-+
-+        Valid default input is a string. Returns a normalized path.
-+
-+        (enabled since Trac 0.11.5)
-+        """
-+        path = self.get(key, default)
-+        if not path:
-+            return default
-+        return self._normalize_path(path, self.env)
-+
-+    def remove(self, key):
-+        """Delete a key from this section.
-+
-+        Like for `set()`, the changes won't persist until `save()` gets called.
-+        """
-+        key_str = to_unicode(key)
-+        option_key = {
-+                'product' : self.product, 
-+                'section' : self.name,
-+                'option' : key_str,
-+            }
-+        try:
-+            setting = ProductSetting(self.env, keys=option_key)
-+        except ResourceNotFound:
-+            self.env.log.warning("No record for product option %s", option_key)
-+        else:
-+            self._cache.pop(key, None)
-+            setting.delete()
-+            self.env.log.info("Removing product option %s", option_key)
-+
-+    def set(self, key, value):
-+        """Change a configuration value.
-+
-+        These changes will be persistent right away.
-+        """
-+        key_str = to_unicode(key)
-+        value_str = to_unicode(value)
-+        self._cache.pop(key_str, None)
-+        option_key = {
-+                'product' : self.product, 
-+                'section' : self.name,
-+                'option' : key_str,
-+            }
-+        try:
-+            setting = ProductSetting(self.env, option_key)
-+        except ResourceNotFound:
-+            if value is not None:
-+                # Insert new record in the database
-+                setting = ProductSetting(self.env)
-+                setting._data.update(option_key)
-+                setting._data['value'] = value_str
-+                self.env.log.debug('Writing option %s', setting._data)
-+                setting.insert()
-+        else:
-+            if value is None:
-+                # Delete existing record from the database
-+                # FIXME : Why bother with setting overriden
-+                self.overridden[key] = True
-+                setting.delete()
-+            else:
-+                # Update existing record
-+                setting.value = value
-+                setting.update()
-+
-+    # Helper methods
-+
-+    @staticmethod
-+    def _normalize_path(path, env):
-+        if not os.path.isabs(path):
-+            path = os.path.join(env.path, 'conf', path)
-+        return os.path.normcase(os.path.realpath(path))
-+
-diff -r 9fce1096e2a7 bloodhound_multiproduct/multiproduct/model.py
---- a/bloodhound_multiproduct/multiproduct/model.py	Tue Jan 08 00:31:17 2013 -0500
-+++ b/bloodhound_multiproduct/multiproduct/model.py	Tue Jan 08 00:50:02 2013 -0500
-@@ -18,6 +18,7 @@
- 
- """Models to support multi-product"""
- from datetime import datetime
-+from itertools import izip
- 
- from trac.core import TracError
- from trac.resource import Resource
-@@ -27,6 +28,10 @@
- 
- from bhdashboard.model import ModelBase
- 
-+# -------------------------------------------
-+# Product API
-+# -------------------------------------------
-+
- 
- class Product(ModelBase):
-     """The Product table"""
-@@ -37,12 +42,12 @@
-             'no_change_fields':['prefix',],
-             'unique_fields':['name'],
-             }
--    
-+
-     @property
-     def resource(self):
-         """Allow Product to be treated as a Resource"""
-         return Resource('product', self.prefix)
--    
-+
-     def delete(self, resources_to=None):
-         """ override the delete method so that we can move references to this
-         object to a new product """
-@@ -59,7 +64,7 @@
-         for prm in ProductResourceMap.select(self._env, where=where):
-             prm._data['product_id'] = resources_to
-             prm.update()
--    
-+
-     def _update_relations(self, db=None, author=None):
-         """Extra actions due to update"""
-         # tickets need to be updated
-@@ -71,7 +76,7 @@
-             for t in Product.get_tickets(self._env, self._data['prefix']):
-                 ticket = Ticket(self._env, t['id'], db)
-                 ticket.save_changes(author, comment, now)
--    
-+
-     @classmethod
-     def get_tickets(cls, env, product=''):
-         """Retrieve all tickets associated with the product."""
-@@ -88,7 +93,7 @@
-             'unique_fields':[],
-             'auto_inc':['id'],
-             }
--    
-+
-     def reparent_resource(self, product=None):
-         """a specific function to update a record when it is to move product"""
-         if product is not None:
-@@ -101,3 +106,39 @@
-         self._data['product_id'] = product
-         self.update()
- 
-+# -------------------------------------------
-+# Configuration
-+# -------------------------------------------
-+
-+class ProductSetting(ModelBase):
-+    """The Product configuration table
-+    """
-+    _meta = {'table_name':'bloodhound_productconfig',
-+            'object_name':'ProductSetting',
-+            'key_fields':['product', 'section', 'option'],
-+            'non_key_fields':['value', ],
-+            'no_change_fields':['product', 'section', 'option'],
-+            'unique_fields':[],
-+            }
-+
-+    @classmethod
-+    def exists(cls, env, product, section=None, option=None, db=None):
-+        """Determine whether there are configuration values for
-+        product, section, option .
-+        """
-+        if product is None:
-+            raise ValueError("Product prefix required")
-+        l = locals()
-+        option_subkey = ([c, l[c]] for c in ('product', 'section', 'option'))
-+        option_subkey = dict(c for c in option_subkey if c[1] is not None)
-+        return len(cls.select(env, db, where=option_subkey, limit=1)) > 0
-+
-+    @classmethod
-+    def get_sections(cls, env, product):
-+        """Retrieve configuration sections defined for a product
-+        """
-+        # FIXME: Maybe something more ORM-ish should be added in ModelBase
-+        return [row[0] for row in env.db_query("""SELECT DISTINCT section 
-+                FROM bloodhound_productconfig WHERE product = %s""", 
-+                (product,)) ]
-+

t115/t115_r1427886_product_envs.diff

-# HG changeset patch
-# Parent d143652322b7ec13c6bd1f0b7aaf7fa01297ba6a
-BH Multiproduct #115 : Product environments
-
-diff -r d143652322b7 bloodhound_multiproduct/multiproduct/env.py
---- /dev/null	Thu Jan 01 00:00:00 1970 +0000
-+++ b/bloodhound_multiproduct/multiproduct/env.py	Thu Jan 03 01:10:05 2013 -0500
-@@ -0,0 +1,476 @@
-+
-+#  Licensed to the Apache Software Foundation (ASF) under one
-+#  or more contributor license agreements.  See the NOTICE file
-+#  distributed with this work for additional information
-+#  regarding copyright ownership.  The ASF licenses this file
-+#  to you under the Apache License, Version 2.0 (the
-+#  "License"); you may not use this file except in compliance
-+#  with the License.  You may obtain a copy of the License at
-+#
-+#   http://www.apache.org/licenses/LICENSE-2.0
-+#
-+#  Unless required by applicable law or agreed to in writing,
-+#  software distributed under the License is distributed on an
-+#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-+#  KIND, either express or implied.  See the License for the
-+#  specific language governing permissions and limitations
-+#  under the License.
-+
-+"""Bloodhound product environment and related APIs"""
-+
-+import os.path
-+
-+from trac.config import ConfigSection, Option
-+from trac.core import Component, ComponentManager, ExtensionPoint, \
-+        implements, TracError
-+from trac.env import Environment, ISystemInfoProvider
-+from trac.util import get_pkginfo, lazy
-+from trac.util.compat import sha1
-+
-+from multiproduct.model import Product
-+
-+class ProductEnvironment(Component, ComponentManager):
-+    """Bloodhound product-aware environment manager.
-+
-+    Bloodhound encapsulates access to product resources stored inside a
-+    Trac environment via product environments. They are compatible lightweight
-+    irepresentations of top level environment. 
-+
-+    Product environments contain among other things:
-+
-+    * a configuration file, 
-+    * product-aware clones of the wiki and ticket attachments files,
-+
-+    Product environments do not have:
-+
-+    * product-specific templates and plugins,
-+    * a separate database
-+    * active participation in database upgrades and other setup tasks
-+
-+    See https://issues.apache.org/bloodhound/wiki/Proposals/BEP-0003
-+    """
-+
-+    implements(ISystemInfoProvider)
-+
-+    @property
-+    def system_info_providers(self):
-+        r"""System info will still be determined by the global environment.
-+        """
-+        return self.env.system_info_providers
-+
-+    @property
-+    def setup_participants(self):
-+        """Setup participants list for product environments will always
-+        be empty based on the fact that upgrades will only be handled by
-+        the global environment.
-+        """
-+        return ()
-+
-+    components_section = ConfigSection('components',
-+        """This section is used to enable or disable components
-+        provided by plugins, as well as by Trac itself.
-+
-+        See also: TracIni , TracPlugins
-+        """)
-+
-+    @property
-+    def shared_plugins_dir():
-+        """Product environments may not add plugins.
-+        """
-+        return ''
-+
-+    # TODO: Estimate product base URL considering global base URL, pattern, ...
-+    base_url = ''
-+
-+    # TODO: Estimate product base URL considering global base URL, pattern, ...
-+    base_url_for_redirect = ''
-+
-+    @property
-+    def secure_cookies(self):
-+        """Restrict cookies to HTTPS connections.
-+        """
-+        return self.env.secure_cookies
-+
-+    @property
-+    def project_name(self):
-+        """Name of the product.
-+        """
-+        return self.product.name
-+
-+    @property
-+    def project_description(self):
-+        """Short description of the product.
-+        """
-+        return self.product.description
-+
-+    @property
-+    def project_url(self):
-+        """URL of the main project web site, usually the website in
-+        which the `base_url` resides. This is used in notification
-+        e-mails.
-+        """
-+        return self.env.project_url
-+
-+    project_admin = Option('project', 'admin', '',
-+        """E-Mail address of the product's leader / administrator.""")
-+
-+    @property
-+    def project_admin_trac_url(self):
-+        """Base URL of a Trac instance where errors in this Trac
-+        should be reported.
-+        """
-+        return self.env.project_admin_trac_url
-+
-+    # FIXME: Should products have different values i.e. config option ?
-+    @property
-+    def project_footer(self):
-+        """Page footer text (right-aligned).
-+        """
-+        return self.env.project_footer
-+
-+    project_icon = Option('project', 'icon', 'common/trac.ico',
-+        """URL of the icon of the product.""")
-+
-+    log_type = Option('logging', 'log_type', 'inherit',
-+        """Logging facility to use.
-+
-+        Should be one of (`inherit`, `none`, `file`, `stderr`, 
-+        `syslog`, `winlog`).""")
-+
-+    log_file = Option('logging', 'log_file', 'trac.log',
-+        """If `log_type` is `file`, this should be a path to the
-+        log-file.  Relative paths are resolved relative to the `log`
-+        directory of the environment.""")
-+
-+    log_level = Option('logging', 'log_level', 'DEBUG',
-+        """Level of verbosity in log.
-+
-+        Should be one of (`CRITICAL`, `ERROR`, `WARN`, `INFO`, `DEBUG`).""")
-+
-+    log_format = Option('logging', 'log_format', None,
-+        """Custom logging format.
-+
-+        If nothing is set, the following will be used:
-+
-+        Trac[$(module)s] $(levelname)s: $(message)s
-+
-+        In addition to regular key names supported by the Python
-+        logger library (see
-+        http://docs.python.org/library/logging.html), one could use:
-+
-+        - $(path)s     the path for the current environment
-+        - $(basename)s the last path component of the current environment
-+        - $(project)s  the project name
-+
-+        Note the usage of `$(...)s` instead of `%(...)s` as the latter form
-+        would be interpreted by the ConfigParser itself.
-+
-+        Example:
-+        `($(thread)d) Trac[$(basename)s:$(module)s] $(levelname)s: $(message)s`
-+
-+        ''(since 0.10.5)''""")
-+
-+    def __init__(self, env, product):
-+        """Initialize the product environment.
-+
-+        :param env:     the global Trac environment
-+        :param product: product prefix or an instance of
-+                        multiproduct.model.Product
-+        """
-+        ComponentManager.__init__(self)
-+
-+        if isinstance(product, Product):
-+            if product._env is not env:
-+                raise ValueError("Product's environment mismatch")
-+        elif isinstance(product, basestring):
-+            products = Product.select(env, where={'prefix': product})
-+            if len(products) == 1 :
-+                product = products[0]
-+            else:
-+                env.log.debug("Products for '%s' : %s",
-+                        product, products)
-+                raise LookupError("Missing product %s" % (product,))
-+
-+        self.env = env
-+        self.product = product
-+        self.systeminfo = []
-+        self._href = self._abs_href = None
-+
-+        self.setup_config()
-+
-+    # ISystemInfoProvider methods
-+
-+    def get_system_info(self):
-+        return self.env.get_system_info()
-+
-+    # Same as parent environment's . Avoid duplicated code
-+    component_activated = Environment.component_activated.im_func
-+    _component_name = Environment._component_name.im_func
-+    _component_rules = Environment._component_rules
-+    enable_component = Environment.enable_component.im_func
-+    get_known_users = Environment.get_known_users.im_func
-+    get_systeminfo = Environment.get_system_info.im_func
-+    get_repository = Environment.get_repository.im_func
-+    is_component_enabled = Environment.is_component_enabled.im_func
-+
-+    def get_db_cnx(self):
-+        """Return a database connection from the connection pool
-+
-+        :deprecated: Use :meth:`db_transaction` or :meth:`db_query` instead
-+
-+        `db_transaction` for obtaining the `db` database connection
-+        which can be used for performing any query
-+        (SELECT/INSERT/UPDATE/DELETE)::
-+
-+           with env.db_transaction as db:
-+               ...
-+
-+
-+        `db_query` for obtaining a `db` database connection which can
-+        be used for performing SELECT queries only::
-+
-+           with env.db_query as db:
-+               ...
-+        """
-+        # TODO: Install database schema proxy with limited scope (see #288)
-+        #return DatabaseManager(self).get_connection()
-+        raise NotImplementedError
-+
-+    @lazy
-+    def db_exc(self):
-+        """Return an object (typically a module) containing all the
-+        backend-specific exception types as attributes, named
-+        according to the Python Database API
-+        (http://www.python.org/dev/peps/pep-0249/).
-+
-+        To catch a database exception, use the following pattern::
-+
-+            try:
-+                with env.db_transaction as db:
-+                    ...
-+            except env.db_exc.IntegrityError, e:
-+                ...
-+        """
-+        return DatabaseManager(self).get_exceptions()
-+
-+    def with_transaction(self, db=None):
-+        """Decorator for transaction functions :deprecated:"""
-+        # TODO: What shall we do ?
-+        #return with_transaction(self, db)
-+        raise NotImplementedError
-+
-+    def get_read_db(self):
-+        """Return a database connection for read purposes :deprecated:
-+
-+        See `trac.db.api.get_read_db` for detailed documentation."""
-+        # TODO: Install database schema proxy with limited scope (see #288)
-+        #return DatabaseManager(self).get_connection(readonly=True)
-+        raise NotImplementedError
-+
-+    @property
-+    def db_query(self):
-+        """Return a context manager which can be used to obtain a
-+        read-only database connection.
-+
-+        Example::
-+
-+            with env.db_query as db:
-+                cursor = db.cursor()
-+                cursor.execute("SELECT ...")
-+                for row in cursor.fetchall():
-+                    ...
-+
-+        Note that a connection retrieved this way can be "called"
-+        directly in order to execute a query::
-+
-+            with env.db_query as db:
-+                for row in db("SELECT ..."):
-+                    ...
-+
-+        If you don't need to manipulate the connection itself, this
-+        can even be simplified to::
-+
-+            for row in env.db_query("SELECT ..."):
-+                ...
-+
-+        :warning: after a `with env.db_query as db` block, though the
-+          `db` variable is still available, you shouldn't use it as it
-+          might have been closed when exiting the context, if this
-+          context was the outermost context (`db_query` or
-+          `db_transaction`).
-+        """
-+        # TODO: Install database schema proxy with limited scope (see #288)
-+        #return QueryContextManager(self)
-+        raise NotImplementedError
-+
-+    @property
-+    def db_transaction(self):
-+        """Return a context manager which can be used to obtain a
-+        writable database connection.
-+
-+        Example::
-+
-+            with env.db_transaction as db:
-+                cursor = db.cursor()
-+                cursor.execute("UPDATE ...")
-+
-+        Upon successful exit of the context, the context manager will
-+        commit the transaction. In case of nested contexts, only the
-+        outermost context performs a commit. However, should an
-+        exception happen, any context manager will perform a rollback.
-+
-+        Like for its read-only counterpart, you can directly execute a
-+        DML query on the `db`::
-+
-+            with env.db_transaction as db:
-+                db("UPDATE ...")
-+
-+        If you don't need to manipulate the connection itself, this
-+        can also be simplified to::
-+
-+            env.db_transaction("UPDATE ...")
-+
-+        :warning: after a `with env.db_transaction` as db` block,
-+          though the `db` variable is still available, you shouldn't
-+          use it as it might have been closed when exiting the
-+          context, if this context was the outermost context
-+          (`db_query` or `db_transaction`).
-+        """
-+        # TODO: Install database schema proxy with limited scope (see #288)
-+        #return TransactionContextManager(self)
-+        raise NotImplementedError
-+
-+    def shutdown(self, tid=None):
-+        """Close the environment."""
-+        RepositoryManager(self).shutdown(tid)
-+        # FIXME: Shared DB so IMO this should not happen ... at least not here
-+        #DatabaseManager(self).shutdown(tid)
-+        if tid is None:
-+            self.log.removeHandler(self._log_handler)
-+            self._log_handler.flush()
-+            self._log_handler.close()
-+            del self._log_handler
-+
-+    def create(self, options=[]):
-+        """Placeholder for compatibility when trying to create the basic 
-+        directory structure of the environment, etc ...
-+
-+        This method does nothing at all.
-+        """
-+        # TODO: Handle options args
-+
-+    def get_version(self, db=None, initial=False):
-+        """Return the current version of the database.  If the
-+        optional argument `initial` is set to `True`, the version of
-+        the database used at the time of creation will be returned.
-+
-+        In practice, for database created before 0.11, this will
-+        return `False` which is "older" than any db version number.
-+
-+        :since: 0.11
-+
-+        :since 1.0: deprecation warning: the `db` parameter is no
-+                    longer used and will be removed in version 1.1.1
-+        """
-+        return self.env.get_version(db, initial)
-+
-+    def setup_config(self):
-+        """Load the configuration object.
-+        """
-+        # FIXME: Install product-specific configuration object
-+        self.config = self.env.config
-+        self.setup_log()
-+
-+    def get_templates_dir(self):
-+        """Return absolute path to the templates directory.
-+        """
-+        return self.env.get_templates_dir()
-+
-+    def get_htdocs_dir(self):
-+        """Return absolute path to the htdocs directory."""
-+        return self.env.get_htdocs_dir()
-+
-+    def get_log_dir(self):
-+        """Return absolute path to the log directory."""
-+        return self.env.get_log_dir()
-+
-+    def setup_log(self):
-+        """Initialize the logging sub-system."""
-+        from trac.log import logger_handler_factory
-+        logtype = self.log_type
-+        self.env.log.debug("Log type '%s' for product '%s'", 
-+                logtype, self.product.prefix)
-+        if logtype == 'inherit':
-+            logtype = self.env.log_type
-+            logfile = self.env.log_file
-+            format = self.env.log_format
-+        else:
-+            logfile = self.log_file
-+            format = self.log_format
-+        if logtype == 'file' and not os.path.isabs(logfile):
-+            logfile = os.path.join(self.get_log_dir(), logfile)
-+        logid = 'Trac.%s.%s' % \
-+                (sha1(self.env.path).hexdigest(), self.product.prefix)
-+        if format:
-+            format = format.replace('$(', '%(') \
-+                     .replace('%(path)s', self.path) \
-+                     .replace('%(basename)s', os.path.basename(self.path)) \
-+                     .replace('%(project)s', self.project_name)
-+        self.log, self._log_handler = logger_handler_factory(
-+            logtype, logfile, self.log_level, logid, format=format)
-+
-+        from trac import core, __version__ as VERSION
-+        self.log.info('-' * 32 + ' environment startup [Trac %s] ' + '-' * 32,
-+                      get_pkginfo(core).get('version', VERSION))
-+
-+    def backup(self, dest=None):
-+        """Create a backup of the database.
-+
-+        :param dest: Destination file; if not specified, the backup is
-+                     stored in a file called db_name.trac_version.bak
-+        """
-+        return self.env.backup(dest)
-+
-+    def needs_upgrade(self):
-+        """Return whether the environment needs to be upgraded."""
-+        #for participant in self.setup_participants:
-+        #    with self.db_query as db:
-+        #        if participant.environment_needs_upgrade(db):
-+        #            self.log.warn("Component %s requires environment upgrade",
-+        #                          participant)
-+        #            return True
-+
-+        # FIXME: For the time being no need to upgrade the environment
-+        # TODO: Determine the role of product environments at upgrade time
-+        return False
-+
-+    def upgrade(self, backup=False, backup_dest=None):
-+        """Upgrade database.
-+
-+        :param backup: whether or not to backup before upgrading
-+        :param backup_dest: name of the backup file
-+        :return: whether the upgrade was performed
-+        """
-+        # (Database) upgrades handled by global environment
-+        # FIXME: True or False ?
-+        return True
-+
-+    @property
-+    def href(self):
-+        """The application root path"""
-+        if not self._href:
-+            self._href = Href(urlsplit(self.abs_href.base)[2])
-+        return self._href
-+
-+    @property
-+    def abs_href(self):
-+        """The application URL"""
-+        if not self._abs_href:
-+            if not self.base_url:
-+                self.log.warn("base_url option not set in configuration, "
-+                              "generated links may be incorrect")
-+                self._abs_href = Href('')
-+            else:
-+                self._abs_href = Href(self.base_url)
-+        return self._abs_href
-+

t115/t115_r1429886_product_config.diff

+# HG changeset patch
+# Parent b4d5205ef4158da8ebeded7c9c939a574a74455c
+BH Multiproduct #115 : Product-specific settings
+
+diff -r b4d5205ef415 bloodhound_dashboard/bhdashboard/model.py
+--- a/bloodhound_dashboard/bhdashboard/model.py	Tue Jan 08 02:01:28 2013 -0500
++++ b/bloodhound_dashboard/bhdashboard/model.py	Tue Jan 08 02:04:21 2013 -0500
+@@ -118,8 +118,9 @@
+                 self._update_from_row(row)
+                 break
+             else:
+-                raise ResourceNotFound('No %(object_name)s with %(where)s' %
+-                                sdata)
++                raise ResourceNotFound(
++                        ('No %(object_name)s with %(where)s' % sdata) 
++                                % tuple(values))
+     
+     def delete(self):
+         """Deletes the matching record from the database"""
+@@ -145,14 +146,15 @@
+                                       for k in self._meta['key_fields']]))):
+             sdata = {'keys':','.join(["%s='%s'" % (k, self._data[k])
+                                      for k in self._meta['key_fields']])}
+-        elif len(self.select(self._env, where =
++        elif self._meta['unique_fields'] and len(self.select(self._env, where =
+                                 dict([(k,self._data[k])
+                                       for k in self._meta['unique_fields']]))):
+             sdata = {'keys':','.join(["%s='%s'" % (k, self._data[k])
+                                      for k in self._meta['unique_fields']])}
+         if sdata:
+             sdata.update(self._meta)
+-            raise TracError('%(object_name)s %(keys)s already exists' %
++            sdata['values'] = self._data
++            raise TracError('%(object_name)s %(keys)s already exists %(values)s' %
+                             sdata)
+             
+         for key in self._meta['key_fields']:
+@@ -208,7 +210,7 @@
+             TicketSystem(self._env).reset_ticket_fields()
+     
+     @classmethod
+-    def select(cls, env, db=None, where=None):
++    def select(cls, env, db=None, where=None, limit=None):
+         """Query the database to get a set of records back"""
+         rows = []
+         fields = cls._meta['key_fields']+cls._meta['non_key_fields']
+@@ -219,7 +221,11 @@
+         wherestr, values = dict_to_kv_str(where)
+         if wherestr:
+             wherestr = ' WHERE ' + wherestr
+-        for row in env.db_query(sql + wherestr, values):
++        if limit is not None:
++            limitstr = ' LIMIT ' + str(int(limit))
++        else:
++            limitstr = ''
++        for row in env.db_query(sql + wherestr + limitstr, values):
+             # we won't know which class we need until called
+             model = cls.__new__(cls)
+             data = dict([(fields[i], row[i]) for i in range(len(fields))])
+diff -r b4d5205ef415 bloodhound_multiproduct/multiproduct/api.py
+--- a/bloodhound_multiproduct/multiproduct/api.py	Tue Jan 08 02:01:28 2013 -0500
++++ b/bloodhound_multiproduct/multiproduct/api.py	Tue Jan 08 02:04:21 2013 -0500
+@@ -31,9 +31,9 @@
+ from trac.util.translation import _, N_
+ from trac.web.chrome import ITemplateProvider
+ 
+-from multiproduct.model import Product, ProductResourceMap
++from multiproduct.model import Product, ProductResourceMap, ProductSetting
+ 
+-DB_VERSION = 2
++DB_VERSION = 3
+ DB_SYSTEM_KEY = 'bloodhound_multi_product_version'
+ PLUGIN_NAME = 'Bloodhound multi product'
+ 
+@@ -43,7 +43,8 @@
+     implements(IEnvironmentSetupParticipant, ITemplateProvider,
+             IPermissionRequestor, ITicketFieldProvider, IResourceManager)
+     
+-    SCHEMA = [mcls._get_schema() for mcls in (Product, ProductResourceMap)]
++    SCHEMA = [mcls._get_schema() \
++            for mcls in (Product, ProductResourceMap, ProductSetting)]
+     del mcls
+     
+     def get_version(self):
+diff -r b4d5205ef415 bloodhound_multiproduct/multiproduct/config.py
+--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
++++ b/bloodhound_multiproduct/multiproduct/config.py	Tue Jan 08 02:04:21 2013 -0500
+@@ -0,0 +1,311 @@
++
++#  Licensed to the Apache Software Foundation (ASF) under one
++#  or more contributor license agreements.  See the NOTICE file
++#  distributed with this work for additional information
++#  regarding copyright ownership.  The ASF licenses this file
++#  to you under the Apache License, Version 2.0 (the
++#  "License"); you may not use this file except in compliance
++#  with the License.  You may obtain a copy of the License at
++#
++#   http://www.apache.org/licenses/LICENSE-2.0
++#
++#  Unless required by applicable law or agreed to in writing,
++#  software distributed under the License is distributed on an
++#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
++#  KIND, either express or implied.  See the License for the
++#  specific language governing permissions and limitations
++#  under the License.
++
++"""Configuration objects for Bloodhound product environments"""
++
++__all__ = 'Configuration', 'Section'
++
++import os.path
++
++from trac.config import Configuration, ConfigurationError, Option, Section, \
++        _use_default
++from trac.resource import ResourceNotFound
++from trac.util.text import to_unicode
++
++from multiproduct.model import ProductSetting
++
++class Configuration(Configuration):
++    """Product-aware settings repository equivalent to instances of
++    `trac.config.Configuration` (and thus `ConfigParser` from the
++    Python Standard Library) but retrieving configuration values 
++    from the database.
++    """
++    def __init__(self, env, product, parents=None):
++        """Initialize configuration object with an instance of 
++        `trac.env.Environment` and product prefix.
++
++        Optionally it is possible to inherit settings from parent
++        Configuration objects. Environment's configuration will not
++        be added to parents list.
++        """
++        self.env = env
++        self.product = to_unicode(product)
++        self._sections = {}
++        self._setup_parents(parents)
++
++    def __getitem__(self, name):
++        """Return the configuration section with the specified name.
++        """
++        if name not in self._sections:
++            self._sections[name] = Section(self, name)
++        return self._sections[name]
++
++    def sections(self, compmgr=None, defaults=True):
++        """Return a list of section names.
++
++        If `compmgr` is specified, only the section names corresponding to
++        options declared in components that are enabled in the given
++        `ComponentManager` are returned.
++        """
++        sections = set(to_unicode(s) \
++                for s in ProductSetting.get_sections(self.env, self.product))
++        for parent in self.parents:
++            sections.update(parent.sections(compmgr, defaults=False))
++        if defaults:
++            sections.update(self.defaults(compmgr))
++        return sorted(sections)
++
++    def has_option(self, section, option, defaults=True):
++        """Returns True if option exists in section in either the project
++        trac.ini or one of the parents, or is available through the Option
++        registry.
++
++        (since Trac 0.11)
++        """
++        if ProductSetting.exists(self.env, self.product, section, option):
++            return True
++        for parent in self.parents:
++            if parent.has_option(section, option, defaults=False):
++                return True
++        return defaults and (section, option) in Option.registry
++
++    def save(self):
++        """Nothing to do.
++
++        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.
++        """
++
++    def parse_if_needed(self, force=False):
++        """Just invalidate options cache.
++
++        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()
++
++    def touch(self):
++        pass
++
++    def set_defaults(self, compmgr=None):
++        """Retrieve all default values and store them explicitly in the
++        configuration, so that they can be saved to file.
++
++        Values already set in the configuration are not overridden.
++        """
++        for section, default_options in self.defaults(compmgr).items():
++            for name, value in default_options.items():
++                if not ProductSetting.exists(self.env, self.product,
++                        section, name):
++                    if any(parent[section].contains(name, defaults=False)
++                           for parent in self.parents):
++                        value = None
++                    self.set(section, name, value)
++
++    # Helper methods
++
++    def _setup_parents(self, parents=None):
++        """Inherit configuration from parent `Configuration` instances.
++        If there's a value set to 'file' option in 'inherit' section then
++        it will be considered as a list of paths to .ini files
++        that will be added to parents list as well.
++        """
++        from trac import config
++        self.parents = (parents or [])
++        for filename in self.get('inherit', 'file').split(','):
++            filename = Section._normalize_path(filename.strip(), self.env)
++            self.parents.append(config.Configuration(filename))
++
++class Section(Section):
++    """Proxy for a specific configuration section.
++
++    Objects of this class should not be instantiated directly.
++    """
++    __slots__ = ['config', 'name', 'overridden', '_cache']
++
++    def __init__(self, config, name):
++        self.config = config
++        self.name = to_unicode(name)
++        self.overridden = {}
++        self._cache = {}
++
++    @property
++    def env(self):
++        return self.config.env
++
++    @property
++    def product(self):
++        return self.config.product
++
++    def contains(self, key, defaults=True):
++        key = to_unicode(key)
++        if ProductSetting.exists(self.env, self.product, self.name, key):
++            return True
++        for parent in self.config.parents:
++            if parent[self.name].contains(key, defaults=False):
++                return True
++        return defaults and Option.registry.has_key((self.name, key))
++
++    __contains__ = contains
++
++    def iterate(self, compmgr=None, defaults=True):
++        """Iterate over the options in this section.
++
++        If `compmgr` is specified, only return default option values for
++        components that are enabled in the given `ComponentManager`.
++        """
++        options = set()
++        name_str = self.name
++        for setting in ProductSetting.select(self.env,
++                where={'product':self.product, 'section':name_str}):
++            option = to_unicode(setting.option)
++            options.add(option.lower())
++            yield option
++        for parent in self.config.parents:
++            for option in parent[self.name].iterate(defaults=False):
++                loption = option.lower()
++                if loption not in options:
++                    options.add(loption)
++                    yield option
++        if defaults:
++            for section, option in Option.get_registry(compmgr).keys():
++                if section == self.name and option.lower() not in options:
++                    yield option
++
++    __iter__ = iterate
++
++    def __repr__(self):
++        return '<%s [%s , %s]>' % (self.__class__.__name__, \
++                self.product, self.name)
++
++    def get(self, key, default=''):
++        """Return the value of the specified option.
++
++        Valid default input is a string. Returns a string.
++        """
++        cached = self._cache.get(key, _use_default)
++        if cached is not _use_default:
++            return cached
++        name_str = self.name
++        key_str = to_unicode(key)
++        settings = ProductSetting.select(self.env, 
++                where={'product':self.product, 'section':name_str,
++                        'option':key_str})
++        if len(settings) > 0:
++            value = settings[0].value
++        else:
++            for parent in self.config.parents:
++                value = parent[self.name].get(key, _use_default)
++                if value is not _use_default:
++                    break
++            else:
++                if default is not _use_default:
++                    option = Option.registry.get((self.name, key))
++                    value = option.default if option else _use_default
++                else:
++                    value = _use_default
++        if value is _use_default:
++            return default
++        if not value:
++            value = u''
++        elif isinstance(value, basestring):
++            value = to_unicode(value)
++        self._cache[key] = value
++        return value
++
++    def getpath(self, key, default=''):
++        """Return a configuration value as an absolute path.
++
++        Relative paths are resolved relative to `conf` subfolder 
++        of the target global environment. This approach is consistent
++        with TracIni path resolution.
++
++        Valid default input is a string. Returns a normalized path.
++
++        (enabled since Trac 0.11.5)
++        """
++        path = self.get(key, default)
++        if not path:
++            return default
++        return self._normalize_path(path, self.env)
++
++    def remove(self, key):
++        """Delete a key from this section.
++
++        Like for `set()`, the changes won't persist until `save()` gets called.
++        """
++        key_str = to_unicode(key)
++        option_key = {
++                'product' : self.product, 
++                'section' : self.name,
++                'option' : key_str,
++            }
++        try:
++            setting = ProductSetting(self.env, keys=option_key)
++        except ResourceNotFound:
++            self.env.log.warning("No record for product option %s", option_key)
++        else:
++            self._cache.pop(key, None)
++            setting.delete()
++            self.env.log.info("Removing product option %s", option_key)
++
++    def set(self, key, value):
++        """Change a configuration value.
++
++        These changes will be persistent right away.
++        """
++        key_str = to_unicode(key)
++        value_str = to_unicode(value)
++        self._cache.pop(key_str, None)
++        option_key = {
++                'product' : self.product, 
++                'section' : self.name,
++                'option' : key_str,
++            }
++        try:
++            setting = ProductSetting(self.env, option_key)
++        except ResourceNotFound:
++            if value is not None:
++                # Insert new record in the database
++                setting = ProductSetting(self.env)
++                setting._data.update(option_key)
++                setting._data['value'] = value_str
++                self.env.log.debug('Writing option %s', setting._data)
++                setting.insert()
++        else:
++            if value is None:
++                # Delete existing record from the database
++                # FIXME : Why bother with setting overriden
++                self.overridden[key] = True
++                setting.delete()
++            else:
++                # Update existing record
++                setting.value = value
++                setting.update()
++
++    # Helper methods
++
++    @staticmethod
++    def _normalize_path(path, env):
++        if not os.path.isabs(path):
++            path = os.path.join(env.path, 'conf', path)
++        return os.path.normcase(os.path.realpath(path))
++
+diff -r b4d5205ef415 bloodhound_multiproduct/multiproduct/model.py
+--- a/bloodhound_multiproduct/multiproduct/model.py	Tue Jan 08 02:01:28 2013 -0500
++++ b/bloodhound_multiproduct/multiproduct/model.py	Tue Jan 08 02:04:21 2013 -0500
+@@ -18,6 +18,7 @@
+ 
+ """Models to support multi-product"""
+ from datetime import datetime
++from itertools import izip
+ 
+ from trac.core import TracError
+ from trac.resource import Resource
+@@ -27,6 +28,10 @@
+ 
+ from bhdashboard.model import ModelBase
+ 
++# -------------------------------------------
++# Product API
++# -------------------------------------------
++
+ 
+ class Product(ModelBase):
+     """The Product table"""
+@@ -37,12 +42,12 @@
+             'no_change_fields':['prefix',],
+             'unique_fields':['name'],
+             }
+-    
++
+     @property
+     def resource(self):
+         """Allow Product to be treated as a Resource"""
+         return Resource('product', self.prefix)
+-    
++
+     def delete(self, resources_to=None):
+         """ override the delete method so that we can move references to this
+         object to a new product """
+@@ -59,7 +64,7 @@
+         for prm in ProductResourceMap.select(self._env, where=where):
+             prm._data['product_id'] = resources_to
+             prm.update()
+-    
++
+     def _update_relations(self, db=None, author=None):
+         """Extra actions due to update"""
+         # tickets need to be updated
+@@ -71,7 +76,7 @@
+             for t in Product.get_tickets(self._env, self._data['prefix']):
+                 ticket = Ticket(self._env, t['id'], db)
+                 ticket.save_changes(author, comment, now)
+-    
++
+     @classmethod
+     def get_tickets(cls, env, product=''):
+         """Retrieve all tickets associated with the product."""
+@@ -88,7 +93,7 @@
+             'unique_fields':[],
+             'auto_inc_fields': ['id'],
+             }
+-    
++
+     def reparent_resource(self, product=None):
+         """a specific function to update a record when it is to move product"""
+         if product is not None:
+@@ -101,3 +106,39 @@
+         self._data['product_id'] = product
+         self.update()
+ 
++# -------------------------------------------
++# Configuration
++# -------------------------------------------
++
++class ProductSetting(ModelBase):
++    """The Product configuration table
++    """
++    _meta = {'table_name':'bloodhound_productconfig',
++            'object_name':'ProductSetting',
++            'key_fields':['product', 'section', 'option'],
++            'non_key_fields':['value', ],
++            'no_change_fields':['product', 'section', 'option'],
++            'unique_fields':[],
++            }
++
++    @classmethod
++    def exists(cls, env, product, section=None, option=None, db=None):
++        """Determine whether there are configuration values for
++        product, section, option .
++        """
++        if product is None:
++            raise ValueError("Product prefix required")
++        l = locals()
++        option_subkey = ([c, l[c]] for c in ('product', 'section', 'option'))
++        option_subkey = dict(c for c in option_subkey if c[1] is not None)
++        return len(cls.select(env, db, where=option_subkey, limit=1)) > 0
++
++    @classmethod
++    def get_sections(cls, env, product):
++        """Retrieve configuration sections defined for a product
++        """
++        # FIXME: Maybe something more ORM-ish should be added in ModelBase
++        return [row[0] for row in env.db_query("""SELECT DISTINCT section 
++                FROM bloodhound_productconfig WHERE product = %s""", 
++                (product,)) ]
++

t115/t115_r1429886_product_envs.diff

+# HG changeset patch
+# Parent d143652322b7ec13c6bd1f0b7aaf7fa01297ba6a
+BH Multiproduct #115 : Product environments
+
+diff -r d143652322b7 bloodhound_multiproduct/multiproduct/env.py
+--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
++++ b/bloodhound_multiproduct/multiproduct/env.py	Thu Jan 03 01:10:05 2013 -0500
+@@ -0,0 +1,476 @@
++
++#  Licensed to the Apache Software Foundation (ASF) under one
++#  or more contributor license agreements.  See the NOTICE file
++#  distributed with this work for additional information
++#  regarding copyright ownership.  The ASF licenses this file
++#  to you under the Apache License, Version 2.0 (the
++#  "License"); you may not use this file except in compliance
++#  with the License.  You may obtain a copy of the License at
++#
++#   http://www.apache.org/licenses/LICENSE-2.0
++#
++#  Unless required by applicable law or agreed to in writing,
++#  software distributed under the License is distributed on an
++#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
++#  KIND, either express or implied.  See the License for the
++#  specific language governing permissions and limitations
++#  under the License.
++
++"""Bloodhound product environment and related APIs"""
++
++import os.path
++
++from trac.config import ConfigSection, Option
++from trac.core import Component, ComponentManager, ExtensionPoint, \
++        implements, TracError
++from trac.env import Environment, ISystemInfoProvider
++from trac.util import get_pkginfo, lazy
++from trac.util.compat import sha1
++
++from multiproduct.model import Product
++
++class ProductEnvironment(Component, ComponentManager):
++    """Bloodhound product-aware environment manager.
++
++    Bloodhound encapsulates access to product resources stored inside a
++    Trac environment via product environments. They are compatible lightweight
++    irepresentations of top level environment. 
++
++    Product environments contain among other things:
++
++    * a configuration file, 
++    * product-aware clones of the wiki and ticket attachments files,
++
++    Product environments do not have:
++
++    * product-specific templates and plugins,
++    * a separate database
++    * active participation in database upgrades and other setup tasks
++
++    See https://issues.apache.org/bloodhound/wiki/Proposals/BEP-0003
++    """
++
++    implements(ISystemInfoProvider)
++
++    @property
++    def system_info_providers(self):
++        r"""System info will still be determined by the global environment.
++        """
++        return self.env.system_info_providers
++
++    @property
++    def setup_participants(self):
++        """Setup participants list for product environments will always
++        be empty based on the fact that upgrades will only be handled by
++        the global environment.
++        """
++        return ()
++
++    components_section = ConfigSection('components',
++        """This section is used to enable or disable components
++        provided by plugins, as well as by Trac itself.
++
++        See also: TracIni , TracPlugins
++        """)
++
++    @property
++    def shared_plugins_dir():
++        """Product environments may not add plugins.
++        """
++        return ''
++
++    # TODO: Estimate product base URL considering global base URL, pattern, ...
++    base_url = ''
++
++    # TODO: Estimate product base URL considering global base URL, pattern, ...
++    base_url_for_redirect = ''
++
++    @property
++    def secure_cookies(self):
++        """Restrict cookies to HTTPS connections.
++        """
++        return self.env.secure_cookies
++
++    @property
++    def project_name(self):
++        """Name of the product.
++        """
++        return self.product.name
++
++    @property
++    def project_description(self):
++        """Short description of the product.
++        """
++        return self.product.description
++
++    @property
++    def project_url(self):
++        """URL of the main project web site, usually the website in
++        which the `base_url` resides. This is used in notification
++        e-mails.
++        """
++        return self.env.project_url
++
++    project_admin = Option('project', 'admin', '',
++        """E-Mail address of the product's leader / administrator.""")
++
++    @property
++    def project_admin_trac_url(self):
++        """Base URL of a Trac instance where errors in this Trac
++        should be reported.
++        """
++        return self.env.project_admin_trac_url
++
++    # FIXME: Should products have different values i.e. config option ?
++    @property
++    def project_footer(self):
++        """Page footer text (right-aligned).
++        """
++        return self.env.project_footer
++
++    project_icon = Option('project', 'icon', 'common/trac.ico',
++        """URL of the icon of the product.""")
++
++    log_type = Option('logging', 'log_type', 'inherit',
++        """Logging facility to use.
++
++        Should be one of (`inherit`, `none`, `file`, `stderr`, 
++        `syslog`, `winlog`).""")
++
++    log_file = Option('logging', 'log_file', 'trac.log',
++        """If `log_type` is `file`, this should be a path to the
++        log-file.  Relative paths are resolved relative to the `log`
++        directory of the environment.""")
++
++    log_level = Option('logging', 'log_level', 'DEBUG',
++        """Level of verbosity in log.
++
++        Should be one of (`CRITICAL`, `ERROR`, `WARN`, `INFO`, `DEBUG`).""")
++
++    log_format = Option('logging', 'log_format', None,
++        """Custom logging format.
++
++        If nothing is set, the following will be used:
++
++        Trac[$(module)s] $(levelname)s: $(message)s
++
++        In addition to regular key names supported by the Python
++        logger library (see
++        http://docs.python.org/library/logging.html), one could use:
++
++        - $(path)s     the path for the current environment
++        - $(basename)s the last path component of the current environment
++        - $(project)s  the project name
++
++        Note the usage of `$(...)s` instead of `%(...)s` as the latter form
++        would be interpreted by the ConfigParser itself.
++
++        Example:
++        `($(thread)d) Trac[$(basename)s:$(module)s] $(levelname)s: $(message)s`
++
++        ''(since 0.10.5)''""")
++
++    def __init__(self, env, product):
++        """Initialize the product environment.
++
++        :param env:     the global Trac environment
++        :param product: product prefix or an instance of
++                        multiproduct.model.Product
++        """
++        ComponentManager.__init__(self)
++
++        if isinstance(product, Product):
++            if product._env is not env:
++                raise ValueError("Product's environment mismatch")
++        elif isinstance(product, basestring):
++            products = Product.select(env, where={'prefix': product})
++            if len(products) == 1 :
++                product = products[0]
++            else:
++                env.log.debug("Products for '%s' : %s",
++                        product, products)
++                raise LookupError("Missing product %s" % (product,))
++
++        self.env = env
++        self.product = product
++        self.systeminfo = []
++        self._href = self._abs_href = None
++
++        self.setup_config()
++
++    # ISystemInfoProvider methods
++
++    def get_system_info(self):
++        return self.env.get_system_info()
++
++    # Same as parent environment's . Avoid duplicated code
++    component_activated = Environment.component_activated.im_func
++    _component_name = Environment._component_name.im_func
++    _component_rules = Environment._component_rules
++    enable_component = Environment.enable_component.im_func
++    get_known_users = Environment.get_known_users.im_func
++    get_systeminfo = Environment.get_system_info.im_func
++    get_repository = Environment.get_repository.im_func
++    is_component_enabled = Environment.is_component_enabled.im_func
++
++    def get_db_cnx(self):
++        """Return a database connection from the connection pool
++
++        :deprecated: Use :meth:`db_transaction` or :meth:`db_query` instead
++
++        `db_transaction` for obtaining the `db` database connection
++        which can be used for performing any query
++        (SELECT/INSERT/UPDATE/DELETE)::
++
++           with env.db_transaction as db:
++               ...
++