Author: jure
Date: Wed Jan 9 10:24:22 2013
New Revision: 1430768
URL: http://svn.apache.org/viewvc?rev=1430768&view=rev
Log:
Product specific environment & configuration, ticket #115 (from Olemis)
Added:
incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_multiproduct/multiproduct/config.py
incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_multiproduct/multiproduct/env.py
incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_multiproduct/tests/config.py
incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_multiproduct/tests/env.py
Modified:
incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_dashboard/bhdashboard/model.py
incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_multiproduct/multiproduct/api.py
incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_multiproduct/multiproduct/model.py
Modified:
incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_dashboard/bhdashboard/model.py
URL:
http://svn.apache.org/viewvc/incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_dashboard/bhdashboard/model.py?rev=1430768&r1=1430767&r2=1430768&view=diff
==============================================================================
---
incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_dashboard/bhdashboard/model.py
(original)
+++
incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_dashboard/bhdashboard/model.py
Wed Jan 9 10:24:22 2013
@@ -118,8 +118,9 @@ class ModelBase(object):
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 @@ class ModelBase(object):
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 @@ class ModelBase(object):
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 @@ class ModelBase(object):
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))])
Modified:
incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_multiproduct/multiproduct/api.py
URL:
http://svn.apache.org/viewvc/incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_multiproduct/multiproduct/api.py?rev=1430768&r1=1430767&r2=1430768&view=diff
==============================================================================
---
incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_multiproduct/multiproduct/api.py
(original)
+++
incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_multiproduct/multiproduct/api.py
Wed Jan 9 10:24:22 2013
@@ -31,7 +31,7 @@ from trac.ticket.api import ITicketField
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 = 3
DB_SYSTEM_KEY = 'bloodhound_multi_product_version'
@@ -43,9 +43,9 @@ class MultiProductSystem(Component):
implements(IEnvironmentSetupParticipant, ITemplateProvider,
IPermissionRequestor, ITicketFieldProvider, IResourceManager)
- SCHEMA = [mcls._get_schema() for mcls in (Product, ProductResourceMap)]
- del mcls
-
+ SCHEMA = [mcls._get_schema() \
+ for mcls in (Product, ProductResourceMap, ProductSetting)]
+
def get_version(self):
"""Finds the current version of the bloodhound database schema"""
rows = self.env.db_query("""
Added:
incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_multiproduct/multiproduct/config.py
URL:
http://svn.apache.org/viewvc/incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_multiproduct/multiproduct/config.py?rev=1430768&view=auto
==============================================================================
---
incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_multiproduct/multiproduct/config.py
(added)
+++
incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_multiproduct/multiproduct/config.py
Wed Jan 9 10:24:22 2013
@@ -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))
+
Added:
incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_multiproduct/multiproduct/env.py
URL:
http://svn.apache.org/viewvc/incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_multiproduct/multiproduct/env.py?rev=1430768&view=auto
==============================================================================
---
incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_multiproduct/multiproduct/env.py
(added)
+++
incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_multiproduct/multiproduct/env.py
Wed Jan 9 10:24:22 2013
@@ -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
+
Modified:
incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_multiproduct/multiproduct/model.py
URL:
http://svn.apache.org/viewvc/incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_multiproduct/multiproduct/model.py?rev=1430768&r1=1430767&r2=1430768&view=diff
==============================================================================
---
incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_multiproduct/multiproduct/model.py
(original)
+++
incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_multiproduct/multiproduct/model.py
Wed Jan 9 10:24:22 2013
@@ -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 trac.util.datefmt import utc
from bhdashboard.model import ModelBase
+# -------------------------------------------
+# Product API
+# -------------------------------------------
+
class Product(ModelBase):
"""The Product table"""
@@ -37,12 +42,12 @@ class Product(ModelBase):
'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 @@ class Product(ModelBase):
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 @@ class Product(ModelBase):
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."""
@@ -102,7 +107,7 @@ class ProductResourceMap(ModelBase):
'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:
@@ -115,3 +120,39 @@ class ProductResourceMap(ModelBase):
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,)) ]
+
Added:
incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_multiproduct/tests/config.py
URL:
http://svn.apache.org/viewvc/incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_multiproduct/tests/config.py?rev=1430768&view=auto
==============================================================================
---
incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_multiproduct/tests/config.py
(added)
+++
incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_multiproduct/tests/config.py
Wed Jan 9 10:24:22 2013
@@ -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')
+
Added:
incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_multiproduct/tests/env.py
URL:
http://svn.apache.org/viewvc/incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_multiproduct/tests/env.py?rev=1430768&view=auto
==============================================================================
---
incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_multiproduct/tests/env.py
(added)
+++
incubator/bloodhound/branches/bep_0003_multiproduct/bloodhound_multiproduct/tests/env.py
Wed Jan 9 10:24:22 2013
@@ -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')
+