Package trac :: Package versioncontrol :: Module api

Source Code for Module trac.versioncontrol.api

   1  # -*- coding: utf-8 -*- 
   2  # 
   3  # Copyright (C) 2005-2023 Edgewall Software 
   4  # Copyright (C) 2005 Christopher Lenz <[email protected]> 
   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 <[email protected]> 
  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
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. 1366