Return-Path: X-Original-To: apmail-incubator-bloodhound-commits-archive@minotaur.apache.org Delivered-To: apmail-incubator-bloodhound-commits-archive@minotaur.apache.org Received: from mail.apache.org (hermes.apache.org [140.211.11.3]) by minotaur.apache.org (Postfix) with SMTP id 7261FEED4 for ; Wed, 9 Jan 2013 10:24:58 +0000 (UTC) Received: (qmail 84021 invoked by uid 500); 9 Jan 2013 10:24:53 -0000 Delivered-To: apmail-incubator-bloodhound-commits-archive@incubator.apache.org Received: (qmail 79593 invoked by uid 500); 9 Jan 2013 10:24:47 -0000 Mailing-List: contact bloodhound-commits-help@incubator.apache.org; run by ezmlm Precedence: bulk List-Help: List-Unsubscribe: List-Post: List-Id: Reply-To: bloodhound-dev@incubator.apache.org Delivered-To: mailing list bloodhound-commits@incubator.apache.org Received: (qmail 78303 invoked by uid 99); 9 Jan 2013 10:24:46 -0000 Received: from athena.apache.org (HELO athena.apache.org) (140.211.11.136) by apache.org (qpsmtpd/0.29) with ESMTP; Wed, 09 Jan 2013 10:24:45 +0000 X-ASF-Spam-Status: No, hits=-2000.0 required=5.0 tests=ALL_TRUSTED X-Spam-Check-By: apache.org Received: from [140.211.11.4] (HELO eris.apache.org) (140.211.11.4) by apache.org (qpsmtpd/0.29) with ESMTP; Wed, 09 Jan 2013 10:24:43 +0000 Received: from eris.apache.org (localhost [127.0.0.1]) by eris.apache.org (Postfix) with ESMTP id A5B3323889BB; Wed, 9 Jan 2013 10:24:23 +0000 (UTC) Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 8bit Subject: svn commit: r1430768 - in /incubator/bloodhound/branches/bep_0003_multiproduct: bloodhound_dashboard/bhdashboard/ bloodhound_multiproduct/multiproduct/ bloodhound_multiproduct/tests/ Date: Wed, 09 Jan 2013 10:24:23 -0000 To: bloodhound-commits@incubator.apache.org From: jure@apache.org X-Mailer: svnmailer-1.0.8-patched Message-Id: <20130109102423.A5B3323889BB@eris.apache.org> X-Virus-Checked: Checked by ClamAV on apache.org 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') +