Edgewall Software

source: trunk/trac/versioncontrol/api.py@ 16773

Last change on this file since 16773 was 16773, checked in by Ryan J Ollos, 5 years ago

1.3.4dev: Merge r16771 from 1.2-stable

[skip ci]

Refs #9567.

  • Property svn:eol-style set to native
File size: 50.8 KB
Line 
1# -*- coding: utf-8 -*-
2#
3# Copyright (C) 2005-2018 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 http://trac.edgewall.org/wiki/TracLicense.
10#
11# This software consists of voluntary contributions made by many
12# individuals. For the exact contribution history, see the revision
13# history and logs, available at http://trac.edgewall.org/log/.
14#
15# Author: Christopher Lenz <cmlenz@gmx.de>
16
17import os.path
18from abc import ABCMeta, abstractmethod
19from datetime import datetime
20
21from trac.admin import AdminCommandError, IAdminCommandProvider, get_dir_list
22from trac.config import ConfigSection, Option
23from trac.core import *
24from trac.resource import IResourceManager, Resource, ResourceNotFound
25from trac.util import as_bool, native_path
26from trac.util.concurrency import get_thread_id, threading
27from trac.util.datefmt import time_now, utc
28from trac.util.text import exception_to_unicode, printout, to_unicode
29from trac.util.translation import _
30from trac.web.api import IRequestFilter
31from trac.web.chrome import Chrome, ITemplateProvider, add_warning
32
33
34def is_default(reponame):
35 """Check whether `reponame` is the default repository."""
36 return not reponame or reponame in ('(default)', _('(default)'))
37
38
39class InvalidRepository(TracError):
40 """Exception raised when a repository is invalid."""
41
42
43class InvalidConnector(TracError):
44 """Exception raised when a repository connector is invalid."""
45
46
47class 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
72class 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
108class 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
123class 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
320class 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 "http://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
821class 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
829class 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
839class 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
1106class 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
1260class 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
1348class 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.
Note: See TracBrowser for help on using the repository browser.