"Fossies" - the Fresh Open Source Software Archive

Member "PURELIB/trac/versioncontrol/api.py" (27 Aug 2019, 53377 Bytes) of package /windows/misc/Trac-1.4.win-amd64.exe:


As a special service "Fossies" has tried to format the requested source page into HTML format using (guessed) Python source code syntax highlighting (style: standard) with prefixed line numbers. Alternatively you can here view or download the uninterpreted source code file. See also the last Fossies "Diffs" side-by-side code changes report for "api.py": 1.3.5_vs_1.3.6.

    1 # -*- coding: utf-8 -*-
    2 #
    3 # Copyright (C) 2005-2019 Edgewall Software
    4 # Copyright (C) 2005 Christopher Lenz <cmlenz@gmx.de>
    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 https://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 https://trac.edgewall.org/log/.
   14 #
   15 # Author: Christopher Lenz <cmlenz@gmx.de>
   16 
   17 import os.path
   18 from abc import ABCMeta, abstractmethod
   19 from datetime import datetime
   20 
   21 from trac.admin import AdminCommandError, IAdminCommandProvider, get_dir_list
   22 from trac.config import ConfigSection, Option
   23 from trac.core import *
   24 from trac.resource import IResourceManager, Resource, ResourceNotFound
   25 from trac.util import as_bool, native_path
   26 from trac.util.concurrency import get_thread_id, threading
   27 from trac.util.datefmt import time_now, utc
   28 from trac.util.text import exception_to_unicode, printout, to_unicode
   29 from trac.util.translation import _
   30 from trac.web.api import IRequestFilter
   31 from trac.web.chrome import Chrome, ITemplateProvider, add_warning
   32 
   33 
   34 def is_default(reponame):
   35     """Check whether `reponame` is the default repository."""
   36     return not reponame or reponame in ('(default)', _('(default)'))
   37 
   38 
   39 class InvalidRepository(TracError):
   40     """Exception raised when a repository is invalid."""
   41 
   42 
   43 class InvalidConnector(TracError):
   44     """Exception raised when a repository connector is invalid."""
   45 
   46 
   47 class IRepositoryConnector(Interface):
   48     """Provide support for a specific version control system."""
   49 
   50     error = None  # place holder for storing relevant error message
   51 
   52     def get_supported_types():
   53         """Return the types of version control systems that are supported.
   54 
   55         Yields `(repotype, priority)` pairs, where `repotype` is used to
   56         match against the repository's `type` attribute.
   57 
   58         If multiple provider match a given type, the `priority` is used to
   59         choose between them (highest number is highest priority).
   60 
   61         If the `priority` returned is negative, this indicates that the
   62         connector for the given `repotype` indeed exists but can't be
   63         used for some reason. The `error` property can then be used to
   64         store an error message or exception relevant to the problem detected.
   65         """
   66 
   67     def get_repository(repos_type, repos_dir, params):
   68         """Return a Repository instance for the given repository type and dir.
   69         """
   70 
   71 
   72 class IRepositoryProvider(Interface):
   73     """Provide known named instances of Repository."""
   74 
   75     def get_repositories():
   76         """Generate repository information for known repositories.
   77 
   78         Repository information is a key,value pair, where the value is
   79         a dictionary which must contain at the very least either of
   80         the following entries:
   81 
   82          - `'dir'`: the repository directory which can be used by the
   83                     connector to create a `Repository` instance. This
   84                     defines a "real" repository.
   85 
   86          - `'alias'`: the name of another repository. This defines an
   87                       alias to another (real) repository.
   88 
   89         Optional entries:
   90 
   91          - `'type'`: the type of the repository (if not given, the
   92                      default repository type will be used).
   93 
   94          - `'description'`: a description of the repository (can
   95                             contain WikiFormatting).
   96 
   97          - `'hidden'`: if set to `'true'`, the repository is hidden
   98                        from the repository index (default: `'false'`).
   99 
  100          - `'sync_per_request'`: if set to `'true'`, the repository will be
  101                                  synchronized on every request (default:
  102                                  `'false'`).
  103 
  104          - `'url'`: the base URL for checking out the repository.
  105         """
  106 
  107 
  108 class IRepositoryChangeListener(Interface):
  109     """Listen for changes in repositories."""
  110 
  111     def changeset_added(repos, changeset):
  112         """Called after a changeset has been added to a repository."""
  113 
  114     def changeset_modified(repos, changeset, old_changeset):
  115         """Called after a changeset has been modified in a repository.
  116 
  117         The `old_changeset` argument contains the metadata of the changeset
  118         prior to the modification. It is `None` if the old metadata cannot
  119         be retrieved.
  120         """
  121 
  122 
  123 class DbRepositoryProvider(Component):
  124     """Component providing repositories registered in the DB."""
  125 
  126     implements(IRepositoryProvider, IAdminCommandProvider)
  127 
  128     repository_attrs = ('alias', 'description', 'dir', 'hidden', 'name',
  129                         'sync_per_request', 'type', 'url')
  130 
  131     # IRepositoryProvider methods
  132 
  133     def get_repositories(self):
  134         """Retrieve repositories specified in the repository DB table."""
  135         repos = {}
  136         for id, name, value in self.env.db_query(
  137                 "SELECT id, name, value FROM repository WHERE name IN (%s)"
  138                 % ",".join("'%s'" % each for each in self.repository_attrs)):
  139             if value is not None:
  140                 repos.setdefault(id, {})[name] = value
  141         reponames = {}
  142         for id, info in repos.iteritems():
  143             if 'name' in info and ('dir' in info or 'alias' in info):
  144                 info['id'] = id
  145                 reponames[info['name']] = info
  146             info['sync_per_request'] = as_bool(info.get('sync_per_request'))
  147         return reponames.iteritems()
  148 
  149     # IAdminCommandProvider methods
  150 
  151     def get_admin_commands(self):
  152         yield ('repository add', '<repos> <dir> [type]',
  153                "Add a source repository",
  154                self._complete_add, self._do_add)
  155         yield ('repository alias', '<name> <target>',
  156                "Create an alias for a repository",
  157                self._complete_alias, self._do_alias)
  158         yield ('repository remove', '<repos>',
  159                "Remove a source repository",
  160                self._complete_repos, self._do_remove)
  161         yield ('repository set', '<repos> <key> <value>',
  162                """Set an attribute of a repository
  163 
  164                The following keys are supported: %s
  165                """ % ', '.join(self.repository_attrs),
  166                self._complete_set, self._do_set)
  167 
  168     def get_reponames(self):
  169         rm = RepositoryManager(self.env)
  170         return [reponame or '(default)' for reponame
  171                 in rm.get_all_repositories()]
  172 
  173     def _complete_add(self, args):
  174         if len(args) == 2:
  175             return get_dir_list(args[-1], True)
  176         elif len(args) == 3:
  177             return RepositoryManager(self.env).get_supported_types()
  178 
  179     def _complete_alias(self, args):
  180         if len(args) == 2:
  181             return self.get_reponames()
  182 
  183     def _complete_repos(self, args):
  184         if len(args) == 1:
  185             return self.get_reponames()
  186 
  187     def _complete_set(self, args):
  188         if len(args) == 1:
  189             return self.get_reponames()
  190         elif len(args) == 2:
  191             return self.repository_attrs
  192 
  193     def _do_add(self, reponame, dir, type_=None):
  194         self.add_repository(reponame, os.path.abspath(dir), type_)
  195 
  196     def _do_alias(self, reponame, target):
  197         self.add_alias(reponame, target)
  198 
  199     def _do_remove(self, reponame):
  200         self.remove_repository(reponame)
  201 
  202     def _do_set(self, reponame, key, value):
  203         if key not in self.repository_attrs:
  204             raise AdminCommandError(_('Invalid key "%(key)s"', key=key))
  205         if key == 'dir':
  206             value = os.path.abspath(value)
  207         self.modify_repository(reponame, {key: value})
  208         if not reponame:
  209             reponame = '(default)'
  210         if key == 'dir':
  211             printout(_('You should now run "repository resync %(name)s".',
  212                        name=reponame))
  213         elif key == 'type':
  214             printout(_('You may have to run "repository resync %(name)s".',
  215                        name=reponame))
  216 
  217     # Public interface
  218 
  219     def add_repository(self, reponame, dir, type_=None):
  220         """Add a repository."""
  221         if not os.path.isabs(dir):
  222             raise TracError(_("The repository directory must be absolute"))
  223         if is_default(reponame):
  224             reponame = ''
  225         rm = RepositoryManager(self.env)
  226         if type_ and type_ not in rm.get_supported_types():
  227             raise TracError(_("The repository type '%(type)s' is not "
  228                               "supported", type=type_))
  229         with self.env.db_transaction as db:
  230             id = rm.get_repository_id(reponame)
  231             db.executemany(
  232                 "INSERT INTO repository (id, name, value) VALUES (%s, %s, %s)",
  233                 [(id, 'dir', dir),
  234                  (id, 'type', type_ or '')])
  235         rm.reload_repositories()
  236 
  237     def add_alias(self, reponame, target):
  238         """Create an alias repository."""
  239         if is_default(reponame):
  240             reponame = ''
  241         if is_default(target):
  242             target = ''
  243         rm = RepositoryManager(self.env)
  244         repositories = rm.get_all_repositories()
  245         if target not in repositories:
  246             raise TracError(_("Repository \"%(repo)s\" doesn't exist",
  247                               repo=target or '(default)'))
  248         if 'alias' in repositories[target]:
  249             raise TracError(_('Cannot create an alias to the alias "%(repo)s"',
  250                               repo=target or '(default)'))
  251         with self.env.db_transaction as db:
  252             id = rm.get_repository_id(reponame)
  253             db.executemany(
  254                 "INSERT INTO repository (id, name, value) VALUES (%s, %s, %s)",
  255                 [(id, 'dir', None),
  256                  (id, 'alias', target)])
  257         rm.reload_repositories()
  258 
  259     def remove_repository(self, reponame):
  260         """Remove a repository."""
  261         if is_default(reponame):
  262             reponame = ''
  263         rm = RepositoryManager(self.env)
  264         repositories = rm.get_all_repositories()
  265         if any(reponame == repos.get('alias')
  266                for repos in repositories.itervalues()):
  267             raise TracError(_('Cannot remove the repository "%(repos)s" used '
  268                               'in aliases', repos=reponame or '(default)'))
  269         with self.env.db_transaction as db:
  270             id = rm.get_repository_id(reponame)
  271             db("DELETE FROM repository WHERE id=%s", (id,))
  272             db("DELETE FROM revision WHERE repos=%s", (id,))
  273             db("DELETE FROM node_change WHERE repos=%s", (id,))
  274         rm.reload_repositories()
  275 
  276     def modify_repository(self, reponame, changes):
  277         """Modify attributes of a repository."""
  278         if is_default(reponame):
  279             reponame = ''
  280         new_reponame = changes.get('name', reponame)
  281         if is_default(new_reponame):
  282             new_reponame = ''
  283         rm = RepositoryManager(self.env)
  284         if reponame != new_reponame:
  285             repositories = rm.get_all_repositories()
  286             if any(reponame == repos.get('alias')
  287                    for repos in repositories.itervalues()):
  288                 raise TracError(_('Cannot rename the repository "%(repos)s" '
  289                                   'used in aliases',
  290                                   repos=reponame or '(default)'))
  291         with self.env.db_transaction as db:
  292             id = rm.get_repository_id(reponame)
  293             if reponame != new_reponame:
  294                 if db("""SELECT id FROM repository WHERE name='name' AND
  295                          value=%s""", (new_reponame,)):
  296                     raise TracError(_('The repository "%(name)s" already '
  297                                       'exists.',
  298                                       name=new_reponame or '(default)'))
  299             for (k, v) in changes.iteritems():
  300                 if k not in self.repository_attrs:
  301                     continue
  302                 if k in ('alias', 'name') and is_default(v):
  303                     v = ''
  304                 if k in ('hidden', 'sync_per_request'):
  305                     v = '1' if as_bool(v) else None
  306                 if k == 'dir' and not os.path.isabs(native_path(v)):
  307                     raise TracError(_("The repository directory must be "
  308                                       "absolute"))
  309                 db("UPDATE repository SET value=%s WHERE id=%s AND name=%s",
  310                    (v, id, k))
  311                 if not db(
  312                         "SELECT value FROM repository WHERE id=%s AND name=%s",
  313                         (id, k)):
  314                     db("""INSERT INTO repository (id, name, value)
  315                           VALUES (%s, %s, %s)
  316                           """, (id, k, v))
  317         rm.reload_repositories()
  318 
  319 
  320 class RepositoryManager(Component):
  321     """Version control system manager."""
  322 
  323     implements(IRequestFilter, IResourceManager, IRepositoryProvider,
  324                ITemplateProvider)
  325 
  326     changeset_realm = 'changeset'
  327     source_realm = 'source'
  328     repository_realm = 'repository'
  329 
  330     connectors = ExtensionPoint(IRepositoryConnector)
  331     providers = ExtensionPoint(IRepositoryProvider)
  332     change_listeners = ExtensionPoint(IRepositoryChangeListener)
  333 
  334     repositories_section = ConfigSection('repositories',
  335         """One of the methods for registering repositories is to
  336         populate the `[repositories]` section of `trac.ini`.
  337 
  338         This is especially suited for setting up aliases, using a
  339         [TracIni#GlobalConfiguration shared configuration], or specifying
  340         repositories at the time of environment creation.
  341 
  342         See [TracRepositoryAdmin#ReposTracIni TracRepositoryAdmin] for
  343         details on the format of this section, and look elsewhere on the
  344         page for information on other repository providers.
  345         """)
  346 
  347     default_repository_type = Option('versioncontrol',
  348                                      'default_repository_type', 'svn',
  349         """Default repository connector type.
  350 
  351         This is used as the default repository type for repositories
  352         defined in the [TracIni#repositories-section repositories] section
  353         or using the "Repositories" admin panel.
  354         """)
  355 
  356     def __init__(self):
  357         self._cache = {}
  358         self._lock = threading.Lock()
  359         self._connectors = None
  360         self._all_repositories = None
  361 
  362     # IRequestFilter methods
  363 
  364     def pre_process_request(self, req, handler):
  365         if handler is not Chrome(self.env):
  366             for repo_info in self.get_all_repositories().values():
  367                 if not as_bool(repo_info.get('sync_per_request')):
  368                     continue
  369                 start = time_now()
  370                 repo_name = repo_info['name'] or '(default)'
  371                 try:
  372                     repo = self.get_repository(repo_info['name'])
  373                     repo.sync()
  374                 except InvalidConnector:
  375                     continue
  376                 except TracError as e:
  377                     add_warning(req,
  378                         _("Can't synchronize with repository \"%(name)s\" "
  379                           "(%(error)s). Look in the Trac log for more "
  380                           "information.", name=repo_name,
  381                           error=to_unicode(e)))
  382                 except Exception as e:
  383                     add_warning(req,
  384                         _("Failed to sync with repository \"%(name)s\": "
  385                           "%(error)s; repository information may be out of "
  386                           "date. Look in the Trac log for more information "
  387                           "including mitigation strategies.",
  388                           name=repo_name, error=to_unicode(e)))
  389                     self.log.error(
  390                         "Failed to sync with repository \"%s\"; You may be "
  391                         "able to reduce the impact of this issue by "
  392                         "configuring the sync_per_request option; see "
  393                         "https://trac.edgewall.org/wiki/TracRepositoryAdmin"
  394                         "#ExplicitSync for more detail: %s", repo_name,
  395                         exception_to_unicode(e, traceback=True))
  396                 self.log.info("Synchronized '%s' repository in %0.2f seconds",
  397                               repo_name, time_now() - start)
  398         return handler
  399 
  400     def post_process_request(self, req, template, data, metadata):
  401         return template, data, metadata
  402 
  403     # IResourceManager methods
  404 
  405     def get_resource_realms(self):
  406         yield self.changeset_realm
  407         yield self.source_realm
  408         yield self.repository_realm
  409 
  410     def get_resource_description(self, resource, format=None, **kwargs):
  411         if resource.realm == self.changeset_realm:
  412             parent = resource.parent
  413             reponame = parent and parent.id
  414             id = resource.id
  415             if reponame:
  416                 return _("Changeset %(rev)s in %(repo)s", rev=id, repo=reponame)
  417             else:
  418                 return _("Changeset %(rev)s", rev=id)
  419         elif resource.realm == self.source_realm:
  420             parent = resource.parent
  421             reponame = parent and parent.id
  422             id = resource.id
  423             version = ''
  424             if format == 'summary':
  425                 repos = self.get_repository(reponame)
  426                 node = repos.get_node(resource.id, resource.version)
  427                 if node.isdir:
  428                     kind = _("directory")
  429                 elif node.isfile:
  430                     kind = _("file")
  431                 if resource.version:
  432                     version = _(" at version %(rev)s", rev=resource.version)
  433             else:
  434                 kind = _("path")
  435                 if resource.version:
  436                     version = '@%s' % resource.version
  437             in_repo = _(" in %(repo)s", repo=reponame) if reponame else ''
  438             # TRANSLATOR: file /path/to/file.py at version 13 in reponame
  439             return _('%(kind)s %(id)s%(at_version)s%(in_repo)s',
  440                      kind=kind, id=id, at_version=version, in_repo=in_repo)
  441         elif resource.realm == self.repository_realm:
  442             if not resource.id:
  443                 return _("Default repository")
  444             return _("Repository %(repo)s", repo=resource.id)
  445 
  446     def get_resource_url(self, resource, href, **kwargs):
  447         if resource.realm == self.changeset_realm:
  448             parent = resource.parent
  449             return href.changeset(resource.id, parent and parent.id or None)
  450         elif resource.realm == self.source_realm:
  451             parent = resource.parent
  452             return href.browser(parent and parent.id or None, resource.id,
  453                                 rev=resource.version or None)
  454         elif resource.realm == self.repository_realm:
  455             return href.browser(resource.id or None)
  456 
  457     def resource_exists(self, resource):
  458         if resource.realm == self.repository_realm:
  459             reponame = resource.id
  460         else:
  461             reponame = resource.parent.id
  462         repos = RepositoryManager(self.env).get_repository(reponame)
  463         if not repos:
  464             return False
  465         if resource.realm == self.changeset_realm:
  466             try:
  467                 repos.get_changeset(resource.id)
  468                 return True
  469             except NoSuchChangeset:
  470                 return False
  471         elif resource.realm == self.source_realm:
  472             try:
  473                 repos.get_node(resource.id, resource.version)
  474                 return True
  475             except NoSuchNode:
  476                 return False
  477         elif resource.realm == self.repository_realm:
  478             return True
  479 
  480     # IRepositoryProvider methods
  481 
  482     def get_repositories(self):
  483         """Retrieve repositories specified in TracIni.
  484 
  485         The `[repositories]` section can be used to specify a list
  486         of repositories.
  487         """
  488         repositories = self.repositories_section
  489         reponames = {}
  490         # first pass to gather the <name>.dir entries
  491         for option in repositories:
  492             if option.endswith('.dir') and repositories.get(option):
  493                 reponames[option[:-4]] = {'sync_per_request': False}
  494         # second pass to gather aliases
  495         for option in repositories:
  496             alias = repositories.get(option)
  497             if '.' not in option:   # Support <alias> = <repo> syntax
  498                 option += '.alias'
  499             if option.endswith('.alias') and alias in reponames:
  500                 reponames.setdefault(option[:-6], {})['alias'] = alias
  501         # third pass to gather the <name>.<detail> entries
  502         for option in repositories:
  503             if '.' in option:
  504                 name, detail = option.rsplit('.', 1)
  505                 if name in reponames and detail != 'alias':
  506                     reponames[name][detail] = repositories.get(option)
  507 
  508         for reponame, info in reponames.iteritems():
  509             yield (reponame, info)
  510 
  511     # ITemplateProvider methods
  512 
  513     def get_htdocs_dirs(self):
  514         return []
  515 
  516     def get_templates_dirs(self):
  517         from pkg_resources import resource_filename
  518         return [resource_filename('trac.versioncontrol', 'templates')]
  519 
  520     # Public API methods
  521 
  522     def get_supported_types(self):
  523         """Return the list of supported repository types."""
  524         types = {type_
  525                  for connector in self.connectors
  526                  for (type_, prio) in connector.get_supported_types() or []
  527                  if prio >= 0}
  528         return list(types)
  529 
  530     def get_repositories_by_dir(self, directory):
  531         """Retrieve the repositories based on the given directory.
  532 
  533            :param directory: the key for identifying the repositories.
  534            :return: list of `Repository` instances.
  535         """
  536         directory = os.path.join(os.path.normcase(native_path(directory)), '')
  537         repositories = []
  538         for reponame, repoinfo in self.get_all_repositories().iteritems():
  539             dir = native_path(repoinfo.get('dir'))
  540             if dir:
  541                 dir = os.path.join(os.path.normcase(dir), '')
  542                 if dir.startswith(directory):
  543                     repos = self.get_repository(reponame)
  544                     if repos:
  545                         repositories.append(repos)
  546         return repositories
  547 
  548     def get_repository_id(self, reponame):
  549         """Return a unique id for the given repository name.
  550 
  551         This will create and save a new id if none is found.
  552 
  553         Note: this should probably be renamed as we're dealing
  554               exclusively with *db* repository ids here.
  555         """
  556         with self.env.db_transaction as db:
  557             for id, in db(
  558                     "SELECT id FROM repository WHERE name='name' AND value=%s",
  559                     (reponame,)):
  560                 return id
  561 
  562             id = db("SELECT COALESCE(MAX(id), 0) FROM repository")[0][0] + 1
  563             db("INSERT INTO repository (id, name, value) VALUES (%s, %s, %s)",
  564                (id, 'name', reponame))
  565             return id
  566 
  567     def get_repository(self, reponame):
  568         """Retrieve the appropriate `Repository` for the given
  569         repository name.
  570 
  571         :param reponame: the key for specifying the repository.
  572                          If no name is given, take the default
  573                          repository.
  574         :return: if no corresponding repository was defined,
  575                  simply return `None`.
  576 
  577         :raises InvalidConnector: if the repository connector cannot be
  578                                   opened.
  579         :raises InvalidRepository: if the repository cannot be opened.
  580         """
  581         reponame = reponame or ''
  582         repoinfo = self.get_all_repositories().get(reponame, {})
  583         if 'alias' in repoinfo:
  584             reponame = repoinfo['alias']
  585             repoinfo = self.get_all_repositories().get(reponame, {})
  586         rdir = native_path(repoinfo.get('dir'))
  587         if not rdir:
  588             return None
  589         rtype = repoinfo.get('type') or self.default_repository_type
  590 
  591         # get a Repository for the reponame (use a thread-level cache)
  592         with self.env.db_transaction:  # prevent possible deadlock, see #4465
  593             with self._lock:
  594                 tid = get_thread_id()
  595                 if tid in self._cache:
  596                     repositories = self._cache[tid]
  597                 else:
  598                     repositories = self._cache[tid] = {}
  599                 repos = repositories.get(reponame)
  600                 if not repos:
  601                     if not os.path.isabs(rdir):
  602                         rdir = os.path.join(self.env.path, rdir)
  603                     connector = self._get_connector(rtype)
  604                     repos = connector.get_repository(rtype, rdir,
  605                                                      repoinfo.copy())
  606                     repositories[reponame] = repos
  607                 return repos
  608 
  609     def get_repository_by_path(self, path):
  610         """Retrieve a matching `Repository` for the given `path`.
  611 
  612         :param path: the eventually scoped repository-scoped path
  613         :return: a `(reponame, repos, path)` triple, where `path` is
  614                  the remaining part of `path` once the `reponame` has
  615                  been truncated, if needed.
  616         """
  617         matches = []
  618         path = path.strip('/') + '/' if path else '/'
  619         for reponame in self.get_all_repositories():
  620             stripped_reponame = reponame.strip('/') + '/'
  621             if path.startswith(stripped_reponame):
  622                 matches.append((len(stripped_reponame), reponame))
  623         if matches:
  624             matches.sort()
  625             length, reponame = matches[-1]
  626             path = path[length:]
  627         else:
  628             reponame = ''
  629         return (reponame, self.get_repository(reponame),
  630                 path.rstrip('/') or '/')
  631 
  632     def get_default_repository(self, context):
  633         """Recover the appropriate repository from the current context.
  634 
  635         Lookup the closest source or changeset resource in the context
  636         hierarchy and return the name of its associated repository.
  637         """
  638         while context:
  639             if context.resource.realm in (self.source_realm,
  640                                           self.changeset_realm) and \
  641                     context.resource.parent:
  642                 return context.resource.parent.id
  643             context = context.parent
  644 
  645     def get_all_repositories(self):
  646         """Return a dictionary of repository information, indexed by name."""
  647         if not self._all_repositories:
  648             all_repositories = {}
  649             for provider in self.providers:
  650                 for reponame, info in provider.get_repositories() or []:
  651                     if reponame in all_repositories:
  652                         self.log.warning("Discarding duplicate repository "
  653                                          "'%s'", reponame)
  654                     else:
  655                         info['name'] = reponame
  656                         if 'id' not in info:
  657                             info['id'] = self.get_repository_id(reponame)
  658                         all_repositories[reponame] = info
  659             self._all_repositories = all_repositories
  660         return self._all_repositories
  661 
  662     def get_real_repositories(self):
  663         """Return a sorted list of all real repositories (i.e. excluding
  664         aliases).
  665         """
  666         repositories = set()
  667         for reponame in self.get_all_repositories():
  668             try:
  669                 repos = self.get_repository(reponame)
  670             except TracError:
  671                 pass  # Skip invalid repositories
  672             else:
  673                 if repos is not None:
  674                     repositories.add(repos)
  675         return sorted(repositories, key=lambda r: r.reponame)
  676 
  677     def reload_repositories(self):
  678         """Reload the repositories from the providers."""
  679         with self._lock:
  680             # FIXME: trac-admin doesn't reload the environment
  681             self._cache = {}
  682             self._all_repositories = None
  683         self.config.touch()     # Force environment reload
  684 
  685     def notify(self, event, reponame, revs):
  686         """Notify repositories and change listeners about repository events.
  687 
  688         The supported events are the names of the methods defined in the
  689         `IRepositoryChangeListener` interface.
  690         """
  691         self.log.debug("Event %s on repository '%s' for changesets %r",
  692                        event, reponame or '(default)', revs)
  693 
  694         # Notify a repository by name, and all repositories with the same
  695         # base, or all repositories by base or by repository dir
  696         repos = self.get_repository(reponame)
  697         repositories = []
  698         if repos:
  699             base = repos.get_base()
  700         else:
  701             dir = os.path.abspath(reponame)
  702             repositories = self.get_repositories_by_dir(dir)
  703             if repositories:
  704                 base = None
  705             else:
  706                 base = reponame
  707         if base:
  708             repositories = [r for r in self.get_real_repositories()
  709                             if r.get_base() == base]
  710         if not repositories:
  711             self.log.warning("Found no repositories matching '%s' base.",
  712                              base or reponame)
  713             return [_("Repository '%(repo)s' not found",
  714                       repo=reponame or _("(default)"))]
  715 
  716         errors = []
  717         for repos in sorted(repositories, key=lambda r: r.reponame):
  718             reponame = repos.reponame or '(default)'
  719             repos.sync()
  720             for rev in revs:
  721                 args = []
  722                 if event == 'changeset_modified':
  723                     try:
  724                         old_changeset = repos.sync_changeset(rev)
  725                     except NoSuchChangeset as e:
  726                         errors.append(exception_to_unicode(e))
  727                         self.log.warning(
  728                             "No changeset '%s' found in repository '%s'. "
  729                             "Skipping subscribers for event %s",
  730                             rev, reponame, event)
  731                         continue
  732                     else:
  733                         args.append(old_changeset)
  734                 try:
  735                     changeset = repos.get_changeset(rev)
  736                 except NoSuchChangeset:
  737                     try:
  738                         repos.sync_changeset(rev)
  739                         changeset = repos.get_changeset(rev)
  740                     except NoSuchChangeset as e:
  741                         errors.append(exception_to_unicode(e))
  742                         self.log.warning(
  743                             "No changeset '%s' found in repository '%s'. "
  744                             "Skipping subscribers for event %s",
  745                             rev, reponame, event)
  746                         continue
  747                 self.log.debug("Event %s on repository '%s' for revision '%s'",
  748                                event, reponame, rev)
  749                 for listener in self.change_listeners:
  750                     getattr(listener, event)(repos, changeset, *args)
  751         return errors
  752 
  753     def shutdown(self, tid=None):
  754         """Free `Repository` instances bound to a given thread identifier"""
  755         if tid:
  756             assert tid == get_thread_id()
  757             with self._lock:
  758                 repositories = self._cache.pop(tid, {})
  759                 for reponame, repos in repositories.iteritems():
  760                     repos.close()
  761 
  762     def read_file_by_path(self, path):
  763         """Read the file specified by `path`
  764 
  765         :param path: the repository-scoped path. The repository revision may
  766                      specified by appending `@` followed by the revision,
  767                      otherwise the HEAD revision is assumed.
  768         :return: the file content as a unicode string. `None` is returned if
  769                  the file is not found.
  770 
  771         :since: 1.2.2
  772         """
  773         repos, path = self.get_repository_by_path(path)[1:]
  774         if not repos:
  775             return None
  776         rev = None
  777         if '@' in path:
  778             path, rev = path.split('@', 1)
  779         try:
  780             node = repos.get_node(path, rev)
  781         except (NoSuchChangeset, NoSuchNode):
  782             return None
  783         content = node.get_content()
  784         if content:
  785             return to_unicode(content.read())
  786 
  787     # private methods
  788 
  789     def _get_connector(self, rtype):
  790         """Retrieve the appropriate connector for the given repository type.
  791 
  792         Note that the self._lock must be held when calling this method.
  793         """
  794         if self._connectors is None:
  795             # build an environment-level cache for the preferred connectors
  796             self._connectors = {}
  797             for connector in self.connectors:
  798                 for type_, prio in connector.get_supported_types() or []:
  799                     keep = (connector, prio)
  800                     if type_ in self._connectors and \
  801                             prio <= self._connectors[type_][1]:
  802                         keep = None
  803                     if keep:
  804                         self._connectors[type_] = keep
  805         if rtype in self._connectors:
  806             connector, prio = self._connectors[rtype]
  807             if prio >= 0:  # no error condition
  808                 return connector
  809             else:
  810                 raise InvalidConnector(
  811                     _('Unsupported version control system "%(name)s"'
  812                       ': %(error)s', name=rtype,
  813                       error=to_unicode(connector.error)))
  814         else:
  815             raise InvalidConnector(
  816                 _('Unsupported version control system "%(name)s": '
  817                   'Can\'t find an appropriate component, maybe the '
  818                   'corresponding plugin was not enabled? ', name=rtype))
  819 
  820 
  821 class NoSuchChangeset(ResourceNotFound):
  822     def __init__(self, rev):
  823         ResourceNotFound.__init__(self,
  824                                   _('No changeset %(rev)s in the repository',
  825                                     rev=rev),
  826                                   _('No such changeset'))
  827 
  828 
  829 class NoSuchNode(ResourceNotFound):
  830     def __init__(self, path, rev, msg=None):
  831         if msg is None:
  832             msg = _("No node %(path)s at revision %(rev)s", path=path, rev=rev)
  833         else:
  834             msg = _("%(msg)s: No node %(path)s at revision %(rev)s",
  835                     msg=msg, path=path, rev=rev)
  836         ResourceNotFound.__init__(self, msg, _('No such node'))
  837 
  838 
  839 class Repository(object):
  840     """Base class for a repository provided by a version control system."""
  841 
  842     __metaclass__ = ABCMeta
  843 
  844     has_linear_changesets = False
  845 
  846     scope = '/'
  847 
  848     realm = RepositoryManager.repository_realm
  849 
  850     @property
  851     def resource(self):
  852         return Resource(self.realm, self.reponame)
  853 
  854     def __init__(self, name, params, log):
  855         """Initialize a repository.
  856 
  857            :param name: a unique name identifying the repository, usually a
  858                         type-specific prefix followed by the path to the
  859                         repository.
  860            :param params: a `dict` of parameters for the repository. Contains
  861                           the name of the repository under the key "name" and
  862                           the surrogate key that identifies the repository in
  863                           the database under the key "id".
  864            :param log: a logger instance.
  865 
  866            :raises InvalidRepository: if the repository cannot be opened.
  867         """
  868         self.name = name
  869         self.params = params
  870         self.reponame = params['name']
  871         self.id = params['id']
  872         self.log = log
  873 
  874     def __repr__(self):
  875         return '<%s %r %r %r>' % (self.__class__.__name__,
  876                                   self.id, self.name, self.scope)
  877 
  878     @abstractmethod
  879     def close(self):
  880         """Close the connection to the repository."""
  881         pass
  882 
  883     def get_base(self):
  884         """Return the name of the base repository for this repository.
  885 
  886         This function returns the name of the base repository to which scoped
  887         repositories belong. For non-scoped repositories, it returns the
  888         repository name.
  889         """
  890         return self.name
  891 
  892     def clear(self, youngest_rev=None):
  893         """Clear any data that may have been cached in instance properties.
  894 
  895         `youngest_rev` can be specified as a way to force the value
  896         of the `youngest_rev` property (''will change in 0.12'').
  897         """
  898         pass
  899 
  900     def sync(self, rev_callback=None, clean=False):
  901         """Perform a sync of the repository cache, if relevant.
  902 
  903         If given, `rev_callback` must be a callable taking a `rev` parameter.
  904         The backend will call this function for each `rev` it decided to
  905         synchronize, once the synchronization changes are committed to the
  906         cache. When `clean` is `True`, the cache is cleaned first.
  907         """
  908         pass
  909 
  910     def sync_changeset(self, rev):
  911         """Resync the repository cache for the given `rev`, if relevant.
  912 
  913         Returns a "metadata-only" changeset containing the metadata prior to
  914         the resync, or `None` if the old values cannot be retrieved (typically
  915         when the repository is not cached).
  916         """
  917         return None
  918 
  919     def get_quickjump_entries(self, rev):
  920         """Generate a list of interesting places in the repository.
  921 
  922         `rev` might be used to restrict the list of available locations,
  923         but in general it's best to produce all known locations.
  924 
  925         The generated results must be of the form (category, name, path, rev).
  926         """
  927         return []
  928 
  929     def get_path_url(self, path, rev):
  930         """Return the repository URL for the given path and revision.
  931 
  932         The returned URL can be `None`, meaning that no URL has been specified
  933         for the repository, an absolute URL, or a scheme-relative URL starting
  934         with `//`, in which case the scheme of the request should be prepended.
  935         """
  936         return None
  937 
  938     @abstractmethod
  939     def get_changeset(self, rev):
  940         """Retrieve a Changeset corresponding to the given revision `rev`."""
  941         pass
  942 
  943     def get_changeset_uid(self, rev):
  944         """Return a globally unique identifier for the ''rev'' changeset.
  945 
  946         Two changesets from different repositories can sometimes refer to
  947         the ''very same'' changeset (e.g. the repositories are clones).
  948         """
  949 
  950     def get_changesets(self, start, stop):
  951         """Generate Changeset belonging to the given time period (start, stop).
  952         """
  953         rev = self.youngest_rev
  954         while rev:
  955             chgset = self.get_changeset(rev)
  956             if chgset.date < start:
  957                 return
  958             if chgset.date < stop:
  959                 yield chgset
  960             rev = self.previous_rev(rev)
  961 
  962     def has_node(self, path, rev=None):
  963         """Tell if there's a node at the specified (path,rev) combination.
  964 
  965         When `rev` is `None`, the latest revision is implied.
  966         """
  967         try:
  968             self.get_node(path, rev)
  969             return True
  970         except TracError:
  971             return False
  972 
  973     @abstractmethod
  974     def get_node(self, path, rev=None):
  975         """Retrieve a Node from the repository at the given path.
  976 
  977         A Node represents a directory or a file at a given revision in the
  978         repository.
  979         If the `rev` parameter is specified, the Node corresponding to that
  980         revision is returned, otherwise the Node corresponding to the youngest
  981         revision is returned.
  982         """
  983         pass
  984 
  985     @abstractmethod
  986     def get_oldest_rev(self):
  987         """Return the oldest revision stored in the repository."""
  988         pass
  989     oldest_rev = property(lambda self: self.get_oldest_rev())
  990 
  991     @abstractmethod
  992     def get_youngest_rev(self):
  993         """Return the youngest revision in the repository."""
  994         pass
  995     youngest_rev = property(lambda self: self.get_youngest_rev())
  996 
  997     @abstractmethod
  998     def previous_rev(self, rev, path=''):
  999         """Return the revision immediately preceding the specified revision.
 1000 
 1001         If `path` is given, filter out ancestor revisions having no changes
 1002         below `path`.
 1003 
 1004         In presence of multiple parents, this follows the first parent.
 1005         """
 1006         pass
 1007 
 1008     @abstractmethod
 1009     def next_rev(self, rev, path=''):
 1010         """Return the revision immediately following the specified revision.
 1011 
 1012         If `path` is given, filter out descendant revisions having no changes
 1013         below `path`.
 1014 
 1015         In presence of multiple children, this follows the first child.
 1016         """
 1017         pass
 1018 
 1019     def parent_revs(self, rev):
 1020         """Return a list of parents of the specified revision."""
 1021         parent = self.previous_rev(rev)
 1022         return [parent] if parent is not None else []
 1023 
 1024     @abstractmethod
 1025     def rev_older_than(self, rev1, rev2):
 1026         """Provides a total order over revisions.
 1027 
 1028         Return `True` if `rev1` is an ancestor of `rev2`.
 1029         """
 1030         pass
 1031 
 1032     @abstractmethod
 1033     def get_path_history(self, path, rev=None, limit=None):
 1034         """Retrieve all the revisions containing this path.
 1035 
 1036         If given, `rev` is used as a starting point (i.e. no revision
 1037         ''newer'' than `rev` should be returned).
 1038         The result format should be the same as the one of Node.get_history()
 1039         """
 1040         pass
 1041 
 1042     @abstractmethod
 1043     def normalize_path(self, path):
 1044         """Return a canonical representation of path in the repos."""
 1045         pass
 1046 
 1047     @abstractmethod
 1048     def normalize_rev(self, rev):
 1049         """Return a (unique) canonical representation of a revision.
 1050 
 1051         It's up to the backend to decide which string values of `rev`
 1052         (usually provided by the user) should be accepted, and how they
 1053         should be normalized. Some backends may for instance want to match
 1054         against known tags or branch names.
 1055 
 1056         In addition, if `rev` is `None` or '', the youngest revision should
 1057         be returned.
 1058 
 1059         :raise NoSuchChangeset: If the given `rev` isn't found.
 1060         """
 1061         pass
 1062 
 1063     def short_rev(self, rev):
 1064         """Return a compact string representation of a revision in the
 1065         repos.
 1066 
 1067         :raise NoSuchChangeset: If the given `rev` isn't found.
 1068         :since 1.2: Always returns a string or `None`.
 1069         """
 1070         norm_rev = self.normalize_rev(rev)
 1071         return str(norm_rev) if norm_rev is not None else norm_rev
 1072 
 1073     def display_rev(self, rev):
 1074         """Return a string representation of a revision in the repos for
 1075         displaying to the user.
 1076 
 1077         This can be a shortened revision string, e.g. for repositories
 1078         using long hashes.
 1079 
 1080         :raise NoSuchChangeset: If the given `rev` isn't found.
 1081         :since 1.2: Always returns a string or `None`.
 1082         """
 1083         norm_rev = self.normalize_rev(rev)
 1084         return str(norm_rev) if norm_rev is not None else norm_rev
 1085 
 1086     @abstractmethod
 1087     def get_changes(self, old_path, old_rev, new_path, new_rev,
 1088                     ignore_ancestry=1):
 1089         """Generates changes corresponding to generalized diffs.
 1090 
 1091         Generator that yields change tuples (old_node, new_node, kind, change)
 1092         for each node change between the two arbitrary (path,rev) pairs.
 1093 
 1094         The old_node is assumed to be None when the change is an ADD,
 1095         the new_node is assumed to be None when the change is a DELETE.
 1096         """
 1097         pass
 1098 
 1099     def is_viewable(self, perm):
 1100         """Return True if view permission is granted on the repository."""
 1101         return 'BROWSER_VIEW' in perm(self.resource.child('source', '/'))
 1102 
 1103     can_view = is_viewable  # 0.12 compatibility
 1104 
 1105 
 1106 class Node(object):
 1107     """Represents a directory or file in the repository at a given revision."""
 1108 
 1109     __metaclass__ = ABCMeta
 1110 
 1111     DIRECTORY = "dir"
 1112     FILE = "file"
 1113 
 1114     realm = RepositoryManager.source_realm
 1115 
 1116     @property
 1117     def resource(self):
 1118         return Resource(self.realm, self.path, self.rev, self.repos.resource)
 1119 
 1120     # created_path and created_rev properties refer to the Node "creation"
 1121     # in the Subversion meaning of a Node in a versioned tree (see #3340).
 1122     #
 1123     # Those properties must be set by subclasses.
 1124     #
 1125     created_rev = None
 1126     created_path = None
 1127 
 1128     def __init__(self, repos, path, rev, kind):
 1129         assert kind in (Node.DIRECTORY, Node.FILE), \
 1130                "Unknown node kind %s" % kind
 1131         self.repos = repos
 1132         self.path = to_unicode(path)
 1133         self.rev = rev
 1134         self.kind = kind
 1135 
 1136     def __repr__(self):
 1137         name = u'%s:%s' % (self.repos.name, self.path)
 1138         if self.rev is not None:
 1139             name += '@' + unicode(self.rev)
 1140         return '<%s %r>' % (self.__class__.__name__, name)
 1141 
 1142     @abstractmethod
 1143     def get_content(self):
 1144         """Return a stream for reading the content of the node.
 1145 
 1146         This method will return `None` for directories.
 1147         The returned object must support a `read([len])` method.
 1148         """
 1149         pass
 1150 
 1151     def get_processed_content(self, keyword_substitution=True, eol_hint=None):
 1152         """Return a stream for reading the content of the node, with some
 1153         standard processing applied.
 1154 
 1155         :param keyword_substitution: if `True`, meta-data keywords
 1156             present in the content like ``$Rev$`` are substituted
 1157             (which keyword are substituted and how they are
 1158             substituted is backend specific)
 1159 
 1160         :param eol_hint: which style of line ending is expected if
 1161             `None` was explicitly specified for the file itself in
 1162             the version control backend (for example in Subversion,
 1163             if it was set to ``'native'``).  It can be `None`,
 1164             ``'LF'``, ``'CR'`` or ``'CRLF'``.
 1165         """
 1166         return self.get_content()
 1167 
 1168     @abstractmethod
 1169     def get_entries(self):
 1170         """Generator that yields the immediate child entries of a directory.
 1171 
 1172         The entries are returned in no particular order.
 1173         If the node is a file, this method returns `None`.
 1174         """
 1175         pass
 1176 
 1177     @abstractmethod
 1178     def get_history(self, limit=None):
 1179         """Provide backward history for this Node.
 1180 
 1181         Generator that yields `(path, rev, chg)` tuples, one for each revision
 1182         in which the node was changed. This generator will follow copies and
 1183         moves of a node (if the underlying version control system supports
 1184         that), which will be indicated by the first element of the tuple
 1185         (i.e. the path) changing.
 1186         Starts with an entry for the current revision.
 1187 
 1188         :param limit: if given, yield at most ``limit`` results.
 1189         """
 1190         pass
 1191 
 1192     def get_previous(self):
 1193         """Return the change event corresponding to the previous revision.
 1194 
 1195         This returns a `(path, rev, chg)` tuple.
 1196         """
 1197         skip = True
 1198         for p in self.get_history(2):
 1199             if skip:
 1200                 skip = False
 1201             else:
 1202                 return p
 1203 
 1204     @abstractmethod
 1205     def get_annotations(self):
 1206         """Provide detailed backward history for the content of this Node.
 1207 
 1208         Retrieve an array of revisions, one `rev` for each line of content
 1209         for that node.
 1210         Only expected to work on (text) FILE nodes, of course.
 1211         """
 1212         pass
 1213 
 1214     @abstractmethod
 1215     def get_properties(self):
 1216         """Returns the properties (meta-data) of the node, as a dictionary.
 1217 
 1218         The set of properties depends on the version control system.
 1219         """
 1220         pass
 1221 
 1222     @abstractmethod
 1223     def get_content_length(self):
 1224         """The length in bytes of the content.
 1225 
 1226         Will be `None` for a directory.
 1227         """
 1228         pass
 1229     content_length = property(lambda self: self.get_content_length())
 1230 
 1231     @abstractmethod
 1232     def get_content_type(self):
 1233         """The MIME type corresponding to the content, if known.
 1234 
 1235         Will be `None` for a directory.
 1236         """
 1237         pass
 1238     content_type = property(lambda self: self.get_content_type())
 1239 
 1240     def get_name(self):
 1241         return self.path.split('/')[-1]
 1242     name = property(lambda self: self.get_name())
 1243 
 1244     @abstractmethod
 1245     def get_last_modified(self):
 1246         pass
 1247     last_modified = property(lambda self: self.get_last_modified())
 1248 
 1249     isdir = property(lambda self: self.kind == Node.DIRECTORY)
 1250     isfile = property(lambda self: self.kind == Node.FILE)
 1251 
 1252     def is_viewable(self, perm):
 1253         """Return True if view permission is granted on the node."""
 1254         return ('BROWSER_VIEW' if self.isdir else 'FILE_VIEW') \
 1255                in perm(self.resource)
 1256 
 1257     can_view = is_viewable  # 0.12 compatibility
 1258 
 1259 
 1260 class Changeset(object):
 1261     """Represents a set of changes committed at once in a repository."""
 1262 
 1263     __metaclass__ = ABCMeta
 1264 
 1265     ADD = 'add'
 1266     COPY = 'copy'
 1267     DELETE = 'delete'
 1268     EDIT = 'edit'
 1269     MOVE = 'move'
 1270 
 1271     # change types which can have diff associated to them
 1272     DIFF_CHANGES = (EDIT, COPY, MOVE)  # MERGE
 1273     OTHER_CHANGES = (ADD, DELETE)
 1274     ALL_CHANGES = DIFF_CHANGES + OTHER_CHANGES
 1275 
 1276     realm = RepositoryManager.changeset_realm
 1277 
 1278     @property
 1279     def resource(self):
 1280         return Resource(self.realm, self.rev, parent=self.repos.resource)
 1281 
 1282     def __init__(self, repos, rev, message, author, date):
 1283         self.repos = repos
 1284         self.rev = rev
 1285         self.message = message or ''
 1286         self.author = author or ''
 1287         self.date = date
 1288 
 1289     def __repr__(self):
 1290         name = u'%s@%s' % (self.repos.name, self.rev)
 1291         return '<%s %r>' % (self.__class__.__name__, name)
 1292 
 1293     def get_properties(self):
 1294         """Returns the properties (meta-data) of the node, as a dictionary.
 1295 
 1296         The set of properties depends on the version control system.
 1297 
 1298         Warning: this used to yield 4-elements tuple (besides `name` and
 1299         `text`, there were `wikiflag` and `htmlclass` values).
 1300         This is now replaced by the usage of IPropertyRenderer (see #1601).
 1301         """
 1302         return []
 1303 
 1304     @abstractmethod
 1305     def get_changes(self):
 1306         """Generator that produces a tuple for every change in the changeset.
 1307 
 1308         The tuple will contain `(path, kind, change, base_path, base_rev)`,
 1309         where `change` can be one of Changeset.ADD, Changeset.COPY,
 1310         Changeset.DELETE, Changeset.EDIT or Changeset.MOVE,
 1311         and `kind` is one of Node.FILE or Node.DIRECTORY.
 1312         The `path` is the targeted path for the `change` (which is
 1313         the ''deleted'' path  for a DELETE change).
 1314         The `base_path` and `base_rev` are the source path and rev for the
 1315         action (`None` and `-1` in the case of an ADD change).
 1316         """
 1317         pass
 1318 
 1319     def get_branches(self):
 1320         """Yield branches to which this changeset belong.
 1321         Each branch is given as a pair `(name, head)`, where `name` is
 1322         the branch name and `head` a flag set if the changeset is a head
 1323         for this branch (i.e. if it has no children changeset).
 1324         """
 1325         return []
 1326 
 1327     def get_tags(self):
 1328         """Yield tags associated with this changeset.
 1329 
 1330         .. versionadded :: 1.0
 1331         """
 1332         return []
 1333 
 1334     def get_bookmarks(self):
 1335         """Yield bookmarks associated with this changeset.
 1336 
 1337         .. versionadded :: 1.1.5
 1338         """
 1339         return []
 1340 
 1341     def is_viewable(self, perm):
 1342         """Return True if view permission is granted on the changeset."""
 1343         return 'CHANGESET_VIEW' in perm(self.resource)
 1344 
 1345     can_view = is_viewable  # 0.12 compatibility
 1346 
 1347 
 1348 class EmptyChangeset(Changeset):
 1349     """Changeset that contains no changes. This is typically used when the
 1350     changeset can't be retrieved."""
 1351 
 1352     def __init__(self, repos, rev, message=None, author=None, date=None):
 1353         if date is None:
 1354             date = datetime(1970, 1, 1, tzinfo=utc)
 1355         super(EmptyChangeset, self).__init__(repos, rev, message, author,
 1356                                              date)
 1357 
 1358     def get_changes(self):
 1359         return iter([])
 1360 
 1361 
 1362 # Note: Since Trac 0.12, Exception PermissionDenied class is gone,
 1363 # and class Authorizer is gone as well.
 1364 #
 1365 # Fine-grained permissions are now handled via normal permission policies.