Edgewall Software

source: trunk/trac/env.py

Last change on this file was 14154, checked in by rjollos, 2 hours ago

1.1.6dev: Document --no-backup option of TracAdmin upgrade.

  • Property svn:eol-style set to native
File size: 38.5 KB
Line 
1# -*- coding: utf-8 -*-
2#
3# Copyright (C) 2003-2014 Edgewall Software
4# Copyright (C) 2003-2007 Jonas Borgström <jonas@edgewall.com>
5# All rights reserved.
6#
7# This software is licensed as described in the file COPYING, which
8# you should have received as part of this distribution. The terms
9# are also available at http://trac.edgewall.org/wiki/TracLicense.
10#
11# This software consists of voluntary contributions made by many
12# individuals. For the exact contribution history, see the revision
13# history and logs, available at http://trac.edgewall.org/log/.
14#
15# Author: Jonas Borgström <jonas@edgewall.com>
16
17"""Trac Environment model and related APIs."""
18
19import os.path
20import setuptools
21import sys
22from urlparse import urlsplit
23
24from trac import db_default, log
25from trac.admin import AdminCommandError, IAdminCommandProvider
26from trac.cache import CacheManager, cached
27from trac.config import BoolOption, ChoiceOption, ConfigSection, \
28                        Configuration, Option, PathOption
29from trac.core import Component, ComponentManager, implements, Interface, \
30                      ExtensionPoint, TracBaseError, TracError
31from trac.db.api import (DatabaseManager, QueryContextManager,
32                         TransactionContextManager, with_transaction)
33from trac.util import arity, copytree, create_file, get_pkginfo, lazy, \
34                      makedirs, read_file
35from trac.util.compat import sha1
36from trac.util.concurrency import threading
37from trac.util.text import exception_to_unicode, path_to_unicode, printerr, \
38                           printout
39from trac.util.translation import _, N_
40from trac.versioncontrol import RepositoryManager
41from trac.web.href import Href
42
43__all__ = ['Environment', 'IEnvironmentSetupParticipant', 'open_environment']
44
45
46# Content of the VERSION file in the environment
47_VERSION = 'Trac Environment Version 1'
48
49
50class ISystemInfoProvider(Interface):
51    """Provider of system information, displayed in the "About Trac"
52    page and in internal error reports.
53    """
54    def get_system_info():
55        """Yield a sequence of `(name, version)` tuples describing the
56        name and version information of external packages used by a
57        component.
58        """
59
60
61class IEnvironmentSetupParticipant(Interface):
62    """Extension point interface for components that need to participate in
63    the creation and upgrading of Trac environments, for example to create
64    additional database tables.
65
66    Please note that `IEnvironmentSetupParticipant` instances are called in
67    arbitrary order. If your upgrades must be ordered consistently, please
68    implement the ordering in a single `IEnvironmentSetupParticipant`. See
69    the database upgrade infrastructure in Trac core for an example.
70    """
71
72    def environment_created():
73        """Called when a new Trac environment is created."""
74
75    def environment_needs_upgrade(db=None):
76        """Called when Trac checks whether the environment needs to be
77        upgraded.
78
79        Should return `True` if this participant needs an upgrade to
80        be performed, `False` otherwise.
81
82        :since 1.1.2: the `db` parameter is deprecated and will be removed
83                      in Trac 1.3.1. A database connection should instead be
84                      obtained using a context manager.
85        """
86
87    def upgrade_environment(db=None):
88        """Actually perform an environment upgrade.
89
90        Implementations of this method don't need to commit any
91        database transactions. This is done implicitly for each
92        participant if the upgrade succeeds without an error being
93        raised.
94
95        However, if the `upgrade_environment` consists of small,
96        restartable, steps of upgrade, it can decide to commit on its
97        own after each successful step.
98
99        :since 1.1.2: the `db` parameter is deprecated and will be removed
100                      in Trac 1.3.1. A database connection should instead be
101                      obtained using a context manager.
102        """
103
104
105class BackupError(TracBaseError, RuntimeError):
106    """Exception raised during an upgrade when the DB backup fails."""
107
108
109class Environment(Component, ComponentManager):
110    """Trac environment manager.
111
112    Trac stores project information in a Trac environment. It consists
113    of a directory structure containing among other things:
114
115    * a configuration file,
116    * project-specific templates and plugins,
117    * the wiki and ticket attachments files,
118    * the SQLite database file (stores tickets, wiki pages...)
119      in case the database backend is sqlite
120
121    """
122
123    implements(ISystemInfoProvider)
124
125    required = True
126
127    system_info_providers = ExtensionPoint(ISystemInfoProvider)
128    setup_participants = ExtensionPoint(IEnvironmentSetupParticipant)
129
130    components_section = ConfigSection('components',
131        """This section is used to enable or disable components
132        provided by plugins, as well as by Trac itself. The component
133        to enable/disable is specified via the name of the
134        option. Whether its enabled is determined by the option value;
135        setting the value to `enabled` or `on` will enable the
136        component, any other value (typically `disabled` or `off`)
137        will disable the component.
138
139        The option name is either the fully qualified name of the
140        components or the module/package prefix of the component. The
141        former enables/disables a specific component, while the latter
142        enables/disables any component in the specified
143        package/module.
144
145        Consider the following configuration snippet:
146        {{{
147        [components]
148        trac.ticket.report.ReportModule = disabled
149        acct_mgr.* = enabled
150        }}}
151
152        The first option tells Trac to disable the
153        [wiki:TracReports report module].
154        The second option instructs Trac to enable all components in
155        the `acct_mgr` package. Note that the trailing wildcard is
156        required for module/package matching.
157
158        To view the list of active components, go to the ''Plugins''
159        page on ''About Trac'' (requires `CONFIG_VIEW`
160        [wiki:TracPermissions permissions]).
161
162        See also: TracPlugins
163        """)
164
165    shared_plugins_dir = PathOption('inherit', 'plugins_dir', '',
166        """Path to the //shared plugins directory//.
167
168        Plugins in that directory are loaded in addition to those in
169        the directory of the environment `plugins`, with this one
170        taking precedence.
171        """)
172
173    base_url = Option('trac', 'base_url', '',
174        """Reference URL for the Trac deployment.
175
176        This is the base URL that will be used when producing
177        documents that will be used outside of the web browsing
178        context, like for example when inserting URLs pointing to Trac
179        resources in notification e-mails.""")
180
181    base_url_for_redirect = BoolOption('trac', 'use_base_url_for_redirect',
182                                        False,
183        """Optionally use `[trac] base_url` for redirects.
184
185        In some configurations, usually involving running Trac behind
186        a HTTP proxy, Trac can't automatically reconstruct the URL
187        that is used to access it. You may need to use this option to
188        force Trac to use the `base_url` setting also for
189        redirects. This introduces the obvious limitation that this
190        environment will only be usable when accessible from that URL,
191        as redirects are frequently used.
192        """)
193
194    secure_cookies = BoolOption('trac', 'secure_cookies', False,
195        """Restrict cookies to HTTPS connections.
196
197        When true, set the `secure` flag on all cookies so that they
198        are only sent to the server on HTTPS connections. Use this if
199        your Trac instance is only accessible through HTTPS.
200        """)
201
202    project_name = Option('project', 'name', 'My Project',
203        """Name of the project.""")
204
205    project_description = Option('project', 'descr', 'My example project',
206        """Short description of the project.""")
207
208    project_url = Option('project', 'url', '',
209        """URL of the main project web site, usually the website in
210        which the `base_url` resides. This is used in notification
211        e-mails.""")
212
213    project_admin = Option('project', 'admin', '',
214        """E-Mail address of the project's administrator.""")
215
216    project_admin_trac_url = Option('project', 'admin_trac_url', '.',
217        """Base URL of a Trac instance where errors in this Trac
218        should be reported.
219
220        This can be an absolute or relative URL, or '.' to reference
221        this Trac instance. An empty value will disable the reporting
222        buttons.
223        """)
224
225    project_footer = Option('project', 'footer',
226                            N_('Visit the Trac open source project at<br />'
227                               '<a href="http://trac.edgewall.org/">'
228                               'http://trac.edgewall.org/</a>'),
229        """Page footer text (right-aligned).""")
230
231    project_icon = Option('project', 'icon', 'common/trac.ico',
232        """URL of the icon of the project.""")
233
234    log_type = ChoiceOption('logging', 'log_type',
235                            log.LOG_TYPES + log.LOG_TYPE_ALIASES,
236        """Logging facility to use.
237
238        Should be one of (`none`, `file`, `stderr`, `syslog`, `winlog`).""")
239
240    log_file = Option('logging', 'log_file', 'trac.log',
241        """If `log_type` is `file`, this should be a path to the
242        log-file.  Relative paths are resolved relative to the `log`
243        directory of the environment.""")
244
245    log_level = ChoiceOption('logging', 'log_level',
246                             tuple(reversed(log.LOG_LEVELS)) +
247                             log.LOG_LEVEL_ALIASES,
248        """Level of verbosity in log.
249
250        Should be one of (`CRITICAL`, `ERROR`, `WARNING`, `INFO`, `DEBUG`).
251        """)
252
253    log_format = Option('logging', 'log_format', None,
254        """Custom logging format.
255
256        If nothing is set, the following will be used:
257
258        `Trac[$(module)s] $(levelname)s: $(message)s`
259
260        In addition to regular key names supported by the
261        [http://docs.python.org/library/logging.html Python logger library]
262        one could use:
263
264        - `$(path)s`     the path for the current environment
265        - `$(basename)s` the last path component of the current environment
266        - `$(project)s`  the project name
267
268        Note the usage of `$(...)s` instead of `%(...)s` as the latter form
269        would be interpreted by the !ConfigParser itself.
270
271        Example:
272        `($(thread)d) Trac[$(basename)s:$(module)s] $(levelname)s: $(message)s`
273        """)
274
275    def __init__(self, path, create=False, options=[]):
276        """Initialize the Trac environment.
277
278        :param path:   the absolute path to the Trac environment
279        :param create: if `True`, the environment is created and
280                       populated with default data; otherwise, the
281                       environment is expected to already exist.
282        :param options: A list of `(section, name, value)` tuples that
283                        define configuration options
284        """
285        ComponentManager.__init__(self)
286
287        self.path = path
288        self.log = None
289        self.config = None
290        # System info should be provided through ISystemInfoProvider rather
291        # than appending to systeminfo, which may be a private in a future
292        # release.
293        self.systeminfo = []
294
295        if create:
296            self.create(options)
297            for setup_participant in self.setup_participants:
298                setup_participant.environment_created()
299        else:
300            self.verify()
301            self.setup_config()
302
303    def __repr__(self):
304        return '<%s %r>' % (self.__class__.__name__, self.path)
305
306    @property
307    def env(self):
308        """Property returning the `Environment` object, which is often
309        required for functions and methods that take a `Component` instance.
310        """
311        # The cached decorator requires the object have an `env` attribute.
312        return self
313
314    def get_systeminfo(self):
315        """Return a list of `(name, version)` tuples describing the name
316        and version information of external packages used by Trac and plugins.
317        """
318        info = self.systeminfo[:]
319        for provider in self.system_info_providers:
320            info.extend(provider.get_system_info() or [])
321        info.sort(key=lambda (name, version): (name != 'Trac', name.lower()))
322        return info
323
324    # ISystemInfoProvider methods
325
326    def get_system_info(self):
327        from trac import core, __version__ as VERSION
328        yield 'Trac', get_pkginfo(core).get('version', VERSION)
329        yield 'Python', sys.version
330        yield 'setuptools', setuptools.__version__
331        from trac.util.datefmt import pytz
332        if pytz is not None:
333            yield 'pytz', pytz.__version__
334        if hasattr(self, 'webfrontend_version'):
335            yield self.webfrontend, self.webfrontend_version
336
337    def component_activated(self, component):
338        """Initialize additional member variables for components.
339
340        Every component activated through the `Environment` object
341        gets three member variables: `env` (the environment object),
342        `config` (the environment configuration) and `log` (a logger
343        object)."""
344        component.env = self
345        component.config = self.config
346        component.log = self.log
347
348    def _component_name(self, name_or_class):
349        name = name_or_class
350        if not isinstance(name_or_class, basestring):
351            name = name_or_class.__module__ + '.' + name_or_class.__name__
352        return name.lower()
353
354    @lazy
355    def _component_rules(self):
356        _rules = {}
357        for name, value in self.components_section.options():
358            if name.endswith('.*'):
359                name = name[:-2]
360            _rules[name.lower()] = value.lower() in ('enabled', 'on')
361        return _rules
362
363    def is_component_enabled(self, cls):
364        """Implemented to only allow activation of components that are
365        not disabled in the configuration.
366
367        This is called by the `ComponentManager` base class when a
368        component is about to be activated. If this method returns
369        `False`, the component does not get activated. If it returns
370        `None`, the component only gets activated if it is located in
371        the `plugins` directory of the environment.
372        """
373        component_name = self._component_name(cls)
374
375        rules = self._component_rules
376        cname = component_name
377        while cname:
378            enabled = rules.get(cname)
379            if enabled is not None:
380                return enabled
381            idx = cname.rfind('.')
382            if idx < 0:
383                break
384            cname = cname[:idx]
385
386        # By default, all components in the trac package except
387        # in trac.test or trac.tests are enabled
388        return component_name.startswith('trac.') and \
389               not component_name.startswith('trac.test.') and \
390               not component_name.startswith('trac.tests.') or None
391
392    def enable_component(self, cls):
393        """Enable a component or module."""
394        self._component_rules[self._component_name(cls)] = True
395
396    def verify(self):
397        """Verify that the provided path points to a valid Trac environment
398        directory."""
399        try:
400            tag = read_file(os.path.join(self.path, 'VERSION')).splitlines()[0]
401            if tag != _VERSION:
402                raise Exception(_("Unknown Trac environment type '%(type)s'",
403                                  type=tag))
404        except Exception as e:
405            raise TracError(_("No Trac environment found at %(path)s\n"
406                              "%(e)s", path=self.path, e=e))
407
408    @lazy
409    def db_exc(self):
410        """Return an object (typically a module) containing all the
411        backend-specific exception types as attributes, named
412        according to the Python Database API
413        (http://www.python.org/dev/peps/pep-0249/).
414
415        To catch a database exception, use the following pattern::
416
417            try:
418                with env.db_transaction as db:
419                    ...
420            except env.db_exc.IntegrityError as e:
421                ...
422        """
423        return DatabaseManager(self).get_exceptions()
424
425    def with_transaction(self, db=None):
426        """Decorator for transaction functions.
427
428        :deprecated: Use the query and transaction context managers instead.
429                     Will be removed in Trac 1.3.1.
430        """
431        return with_transaction(self, db)
432
433    def get_read_db(self):
434        """Return a database connection for read purposes.
435
436        See `trac.db.api.get_read_db` for detailed documentation.
437
438        :deprecated: Use :meth:`db_query` instead.
439                     Will be removed in Trac 1.3.1.
440        """
441        return DatabaseManager(self).get_connection(readonly=True)
442
443    @property
444    def db_query(self):
445        """Return a context manager
446        (`~trac.db.api.QueryContextManager`) which can be used to
447        obtain a read-only database connection.
448
449        Example::
450
451            with env.db_query as db:
452                cursor = db.cursor()
453                cursor.execute("SELECT ...")
454                for row in cursor.fetchall():
455                    ...
456
457        Note that a connection retrieved this way can be "called"
458        directly in order to execute a query::
459
460            with env.db_query as db:
461                for row in db("SELECT ..."):
462                    ...
463
464        :warning: after a `with env.db_query as db` block, though the
465          `db` variable is still defined, you shouldn't use it as it
466          might have been closed when exiting the context, if this
467          context was the outermost context (`db_query` or
468          `db_transaction`).
469
470        If you don't need to manipulate the connection itself, this
471        can even be simplified to::
472
473            for row in env.db_query("SELECT ..."):
474                ...
475
476        """
477        return QueryContextManager(self)
478
479    @property
480    def db_transaction(self):
481        """Return a context manager
482        (`~trac.db.api.TransactionContextManager`) which can be used
483        to obtain a writable database connection.
484
485        Example::
486
487            with env.db_transaction as db:
488                cursor = db.cursor()
489                cursor.execute("UPDATE ...")
490
491        Upon successful exit of the context, the context manager will
492        commit the transaction. In case of nested contexts, only the
493        outermost context performs a commit. However, should an
494        exception happen, any context manager will perform a rollback.
495        You should *not* call `commit()` yourself within such block,
496        as this will force a commit even if that transaction is part
497        of a larger transaction.
498
499        Like for its read-only counterpart, you can directly execute a
500        DML query on the `db`::
501
502            with env.db_transaction as db:
503                db("UPDATE ...")
504
505        :warning: after a `with env.db_transaction` as db` block,
506          though the `db` variable is still available, you shouldn't
507          use it as it might have been closed when exiting the
508          context, if this context was the outermost context
509          (`db_query` or `db_transaction`).
510
511        If you don't need to manipulate the connection itself, this
512        can also be simplified to::
513
514            env.db_transaction("UPDATE ...")
515
516        """
517        return TransactionContextManager(self)
518
519    def shutdown(self, tid=None):
520        """Close the environment."""
521        RepositoryManager(self).shutdown(tid)
522        DatabaseManager(self).shutdown(tid)
523        if tid is None:
524            self.log.removeHandler(self._log_handler)
525            self._log_handler.flush()
526            self._log_handler.close()
527            del self._log_handler
528
529    def get_repository(self, reponame=None):
530        """Return the version control repository with the given name,
531        or the default repository if `None`.
532
533        The standard way of retrieving repositories is to use the
534        methods of `RepositoryManager`. This method is retained here
535        for backward compatibility.
536
537        :param reponame: the name of the repository
538        """
539        return RepositoryManager(self).get_repository(reponame)
540
541    def create(self, options=[]):
542        """Create the basic directory structure of the environment,
543        initialize the database and populate the configuration file
544        with default values.
545
546        If options contains ('inherit', 'file'), default values will
547        not be loaded; they are expected to be provided by that file
548        or other options.
549        """
550        # Create the directory structure
551        if not os.path.exists(self.path):
552            os.mkdir(self.path)
553        os.mkdir(self.get_log_dir())
554        os.mkdir(self.get_htdocs_dir())
555        os.mkdir(os.path.join(self.path, 'plugins'))
556
557        # Create a few files
558        create_file(os.path.join(self.path, 'VERSION'), _VERSION + '\n')
559        create_file(os.path.join(self.path, 'README'),
560                    'This directory contains a Trac environment.\n'
561                    'Visit http://trac.edgewall.org/ for more information.\n')
562
563        # Setup the default configuration
564        os.mkdir(os.path.join(self.path, 'conf'))
565        create_file(os.path.join(self.path, 'conf', 'trac.ini.sample'))
566        config = Configuration(os.path.join(self.path, 'conf', 'trac.ini'))
567        for section, name, value in options:
568            config.set(section, name, value)
569        config.save()
570        self.setup_config()
571        if not any((section, option) == ('inherit', 'file')
572                   for section, option, value in options):
573            self.config.set_defaults(self)
574            self.config.save()
575
576        # Create the database
577        DatabaseManager(self).init_db()
578
579    @lazy
580    def database_version(self):
581        """Returns the current version of the database.
582
583        :since 1.0.2:
584        """
585        return DatabaseManager(self) \
586               .get_database_version('database_version')
587
588    @lazy
589    def database_initial_version(self):
590        """Returns the version of the database at the time of creation.
591
592        In practice, for database created before 0.11, this will
593        return `False` which is "older" than any db version number.
594
595        :since 1.0.2:
596        """
597        return DatabaseManager(self) \
598               .get_database_version('initial_database_version')
599
600    def get_version(self, initial=False):
601        """Return the current version of the database.  If the
602        optional argument `initial` is set to `True`, the version of
603        the database used at the time of creation will be returned.
604
605        In practice, for database created before 0.11, this will
606        return `False` which is "older" than any db version number.
607
608        :since: 0.11
609
610        :since 1.0.2: The lazily-evaluated attributes `database_version` and
611                      `database_initial_version` should be used instead. This
612                      method will be removed in release 1.3.1.
613        """
614        dbm = DatabaseManager(self)
615        return dbm.get_database_version(
616            '{0}database_version'.format('initial_' if initial else ''))
617
618    def setup_config(self):
619        """Load the configuration file."""
620        self.config = Configuration(os.path.join(self.path, 'conf',
621                                                 'trac.ini'),
622                                    {'envname': os.path.basename(self.path)})
623        self.setup_log()
624        from trac.loader import load_components
625        plugins_dir = self.shared_plugins_dir
626        load_components(self, plugins_dir and (plugins_dir,))
627
628    def get_templates_dir(self):
629        """Return absolute path to the templates directory."""
630        return os.path.join(self.path, 'templates')
631
632    def get_htdocs_dir(self):
633        """Return absolute path to the htdocs directory."""
634        return os.path.join(self.path, 'htdocs')
635
636    def get_log_dir(self):
637        """Return absolute path to the log directory."""
638        return os.path.join(self.path, 'log')
639
640    def setup_log(self):
641        """Initialize the logging sub-system."""
642        from trac.log import logger_handler_factory
643        logtype = self.log_type
644        logfile = self.log_file
645        if logtype == 'file' and not os.path.isabs(logfile):
646            logfile = os.path.join(self.get_log_dir(), logfile)
647        format = self.log_format
648        logid = 'Trac.%s' % sha1(self.path).hexdigest()
649        if format:
650            format = format.replace('$(', '%(') \
651                     .replace('%(path)s', self.path) \
652                     .replace('%(basename)s', os.path.basename(self.path)) \
653                     .replace('%(project)s', self.project_name)
654        self.log, self._log_handler = logger_handler_factory(
655            logtype, logfile, self.log_level, logid, format=format)
656        from trac import core, __version__ as VERSION
657        self.log.info('-' * 32 + ' environment startup [Trac %s] ' + '-' * 32,
658                      get_pkginfo(core).get('version', VERSION))
659
660    def get_known_users(self):
661        """Generator that yields information about all known users,
662        i.e. users that have logged in to this Trac environment and
663        possibly set their name and email.
664
665        This function generates one tuple for every user, of the form
666        (username, name, email) ordered alpha-numerically by username.
667        """
668        return iter(self._known_users)
669
670    @cached
671    def _known_users(self):
672        return self.db_query("""
673                SELECT DISTINCT s.sid, n.value, e.value
674                FROM session AS s
675                 LEFT JOIN session_attribute AS n ON (n.sid=s.sid
676                  AND n.authenticated=1 AND n.name = 'name')
677                 LEFT JOIN session_attribute AS e ON (e.sid=s.sid
678                  AND e.authenticated=1 AND e.name = 'email')
679                WHERE s.authenticated=1 ORDER BY s.sid
680        """)
681
682    def invalidate_known_users_cache(self):
683        """Clear the known_users cache."""
684        del self._known_users
685
686    def backup(self, dest=None):
687        """Create a backup of the database.
688
689        :param dest: Destination file; if not specified, the backup is
690                     stored in a file called db_name.trac_version.bak
691        """
692        return DatabaseManager(self).backup(dest)
693
694    def needs_upgrade(self):
695        """Return whether the environment needs to be upgraded."""
696        for participant in self.setup_participants:
697            args = ()
698            with self.db_query as db:
699                if arity(participant.environment_needs_upgrade) == 1:
700                    args = (db,)
701                if participant.environment_needs_upgrade(*args):
702                    self.log.warn("Component %s requires environment upgrade",
703                                  participant)
704                    return True
705        return False
706
707    def upgrade(self, backup=False, backup_dest=None):
708        """Upgrade database.
709
710        :param backup: whether or not to backup before upgrading
711        :param backup_dest: name of the backup file
712        :return: whether the upgrade was performed
713        """
714        upgraders = []
715        for participant in self.setup_participants:
716            args = ()
717            with self.db_query as db:
718                if arity(participant.environment_needs_upgrade) == 1:
719                    args = (db,)
720                if participant.environment_needs_upgrade(*args):
721                    upgraders.append(participant)
722        if not upgraders:
723            return
724
725        if backup:
726            try:
727                self.backup(backup_dest)
728            except Exception as e:
729                raise BackupError(e)
730
731        for participant in upgraders:
732            self.log.info("%s.%s upgrading...", participant.__module__,
733                          participant.__class__.__name__)
734            args = ()
735            with self.db_transaction as db:
736                if arity(participant.upgrade_environment) == 1:
737                    args = (db,)
738                participant.upgrade_environment(*args)
739            # Database schema may have changed, so close all connections
740            DatabaseManager(self).shutdown()
741        del self.database_version
742        return True
743
744    @lazy
745    def href(self):
746        """The application root path"""
747        return Href(urlsplit(self.abs_href.base).path)
748
749    @lazy
750    def abs_href(self):
751        """The application URL"""
752        if not self.base_url:
753            self.log.warn("base_url option not set in configuration, "
754                          "generated links may be incorrect")
755        return Href(self.base_url)
756
757
758class EnvironmentSetup(Component):
759    """Manage automatic environment upgrades."""
760
761    required = True
762
763    implements(IEnvironmentSetupParticipant)
764
765    # IEnvironmentSetupParticipant methods
766
767    def environment_created(self):
768        """Insert default data into the database."""
769        DatabaseManager(self.env).insert_into_tables(db_default.get_data)
770        self._update_sample_config()
771
772    def environment_needs_upgrade(self):
773        return DatabaseManager(self.env).needs_upgrade(db_default.db_version)
774
775    def upgrade_environment(self):
776        DatabaseManager(self.env).upgrade(db_default.db_version,
777                                          pkg='trac.upgrades')
778        self._update_sample_config()
779
780    # Internal methods
781
782    def _update_sample_config(self):
783        filename = os.path.join(self.env.path, 'conf', 'trac.ini.sample')
784        if not os.path.isfile(filename):
785            return
786        config = Configuration(filename)
787        for (section, name), option in Option.get_registry().iteritems():
788            config.set(section, name, option.dumps(option.default))
789        try:
790            config.save()
791            self.log.info("Wrote sample configuration file with the new "
792                          "settings and their default values: %s",
793                          filename)
794        except IOError as e:
795            self.log.warn("Couldn't write sample configuration file (%s)", e,
796                          exc_info=True)
797
798
799env_cache = {}
800env_cache_lock = threading.Lock()
801
802
803def open_environment(env_path=None, use_cache=False):
804    """Open an existing environment object, and verify that the database is up
805    to date.
806
807    :param env_path: absolute path to the environment directory; if
808                     omitted, the value of the `TRAC_ENV` environment
809                     variable is used
810    :param use_cache: whether the environment should be cached for
811                      subsequent invocations of this function
812    :return: the `Environment` object
813    """
814    if not env_path:
815        env_path = os.getenv('TRAC_ENV')
816    if not env_path:
817        raise TracError(_('Missing environment variable "TRAC_ENV". '
818                          'Trac requires this variable to point to a valid '
819                          'Trac environment.'))
820
821    env_path = os.path.normcase(os.path.normpath(env_path))
822    if use_cache:
823        with env_cache_lock:
824            env = env_cache.get(env_path)
825            if env and env.config.parse_if_needed():
826                # The environment configuration has changed, so shut it down
827                # and remove it from the cache so that it gets reinitialized
828                env.log.info('Reloading environment due to configuration '
829                             'change')
830                env.shutdown()
831                del env_cache[env_path]
832                env = None
833            if env is None:
834                env = env_cache.setdefault(env_path,
835                                           open_environment(env_path))
836            else:
837                CacheManager(env).reset_metadata()
838    else:
839        env = Environment(env_path)
840        needs_upgrade = False
841        try:
842            needs_upgrade = env.needs_upgrade()
843        except Exception as e:  # e.g. no database connection
844            env.log.error("Exception caught while checking for upgrade: %s",
845                          exception_to_unicode(e, traceback=True))
846        if needs_upgrade:
847            raise TracError(_('The Trac Environment needs to be upgraded.\n\n'
848                              'Run "trac-admin %(path)s upgrade"',
849                              path=env_path))
850
851    return env
852
853
854class EnvironmentAdmin(Component):
855    """trac-admin command provider for environment administration."""
856
857    implements(IAdminCommandProvider)
858
859    # IAdminCommandProvider methods
860
861    def get_admin_commands(self):
862        yield ('deploy', '<directory>',
863               'Extract static resources from Trac and all plugins',
864               None, self._do_deploy)
865        yield ('hotcopy', '<backupdir> [--no-database]',
866               """Make a hot backup copy of an environment
867
868               The database is backed up to the 'db' directory of the
869               destination, unless the --no-database option is
870               specified.
871               """,
872               None, self._do_hotcopy)
873        yield ('upgrade', '[--no-backup]',
874               """Upgrade database to current version
875
876               The database is backed up to the environment 'db' directory,
877               unless the --no-backup option is specified. The shorthand alias
878               -b can also be used to specify --no-backup.
879               """,
880               None, self._do_upgrade)
881
882    def _do_deploy(self, dest):
883        target = os.path.normpath(dest)
884        chrome_target = os.path.join(target, 'htdocs')
885        script_target = os.path.join(target, 'cgi-bin')
886
887        # Copy static content
888        makedirs(target, overwrite=True)
889        makedirs(chrome_target, overwrite=True)
890        from trac.web.chrome import Chrome
891        printout(_("Copying resources from:"))
892        for provider in Chrome(self.env).template_providers:
893            paths = list(provider.get_htdocs_dirs() or [])
894            if not len(paths):
895                continue
896            printout(%s.%s' % (provider.__module__,
897                                  provider.__class__.__name__))
898            for key, root in paths:
899                if not root:
900                    continue
901                source = os.path.normpath(root)
902                printout('   ', source)
903                if os.path.exists(source):
904                    dest = os.path.join(chrome_target, key)
905                    copytree(source, dest, overwrite=True)
906
907        # Create and copy scripts
908        makedirs(script_target, overwrite=True)
909        printout(_("Creating scripts."))
910        data = {'env': self.env, 'executable': sys.executable}
911        for script in ('cgi', 'fcgi', 'wsgi'):
912            dest = os.path.join(script_target, 'trac.' + script)
913            template = Chrome(self.env).load_template('deploy_trac.' + script,
914                                                      'text')
915            stream = template.generate(**data)
916            with open(dest, 'w') as out:
917                stream.render('text', out=out, encoding='utf-8')
918
919    def _do_hotcopy(self, dest, no_db=None):
920        if no_db not in (None, '--no-database'):
921            raise AdminCommandError(_("Invalid argument '%(arg)s'", arg=no_db),
922                                    show_usage=True)
923
924        if os.path.exists(dest):
925            raise TracError(_("hotcopy can't overwrite existing '%(dest)s'",
926                              dest=path_to_unicode(dest)))
927        import shutil
928
929        # Bogus statement to lock the database while copying files
930        with self.env.db_transaction as db:
931            db("UPDATE system SET name=NULL WHERE name IS NULL")
932
933            printout(_("Hotcopying %(src)s to %(dst)s ...",
934                       src=path_to_unicode(self.env.path),
935                       dst=path_to_unicode(dest)))
936            db_str = self.env.config.get('trac', 'database')
937            prefix, db_path = db_str.split(':', 1)
938            skip = []
939
940            if prefix == 'sqlite':
941                db_path = os.path.join(self.env.path,
942                                       os.path.normpath(db_path))
943                # don't copy the journal (also, this would fail on Windows)
944                skip = [db_path + '-journal', db_path + '-stmtjrnl',
945                        db_path + '-shm', db_path + '-wal']
946                if no_db:
947                    skip.append(db_path)
948
949            try:
950                copytree(self.env.path, dest, symlinks=1, skip=skip)
951                retval = 0
952            except shutil.Error as e:
953                retval = 1
954                printerr(_("The following errors happened while copying "
955                           "the environment:"))
956                for (src, dst, err) in e.args[0]:
957                    if src in err:
958                        printerr(%s' % err)
959                    else:
960                        printerr(%s: '%s'" % (err, path_to_unicode(src)))
961
962            # db backup for non-sqlite
963            if prefix != 'sqlite' and not no_db:
964                printout(_("Backing up database ..."))
965                sql_backup = os.path.join(dest, 'db',
966                                          '%s-db-backup.sql' % prefix)
967                self.env.backup(sql_backup)
968
969        printout(_("Hotcopy done."))
970        return retval
971
972    def _do_upgrade(self, no_backup=None):
973        if no_backup not in (None, '-b', '--no-backup'):
974            raise AdminCommandError(_("Invalid arguments"), show_usage=True)
975
976        if not self.env.needs_upgrade():
977            printout(_("Database is up to date, no upgrade necessary."))
978            return
979
980        try:
981            self.env.upgrade(backup=no_backup is None)
982        except BackupError as e:
983            printerr(_("The pre-upgrade backup failed.\nUse '--no-backup' to "
984                       "upgrade without doing a backup.\n"))
985            raise e.args[0]
986        except Exception:
987            printerr(_("The upgrade failed. Please fix the issue and try "
988                       "again.\n"))
989            raise
990
991        # Remove wiki-macros if it is empty and warn if it isn't
992        wiki_macros = os.path.join(self.env.path, 'wiki-macros')
993        try:
994            entries = os.listdir(wiki_macros)
995        except OSError:
996            pass
997        else:
998            if entries:
999                printerr(_("Warning: the wiki-macros directory in the "
1000                           "environment is non-empty, but Trac\n"
1001                           "doesn't load plugins from there anymore. "
1002                           "Please remove it by hand."))
1003            else:
1004                try:
1005                    os.rmdir(wiki_macros)
1006                except OSError as e:
1007                    printerr(_("Error while removing wiki-macros: %(err)s\n"
1008                               "Trac doesn't load plugins from wiki-macros "
1009                               "anymore. Please remove it by hand.",
1010                               err=exception_to_unicode(e)))
1011
1012        printout(_("Upgrade done.\n\n"
1013                   "You may want to upgrade the Trac documentation now by "
1014                   "running:\n\n  trac-admin %(path)s wiki upgrade",
1015                   path=path_to_unicode(self.env.path)))
Note: See TracBrowser for help on using the repository browser.