1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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
35 """Check whether `reponame` is the default repository."""
36 return not reponame or reponame in ('(default)', _('(default)'))
37
40 """Exception raised when a repository is invalid."""
41
44 """Exception raised when a repository connector is invalid."""
45
48 """Provide support for a specific version control system."""
49
50 error = None
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
68 """Return a Repository instance for the given repository type and dir.
69 """
70
73 """Provide known named instances of Repository."""
74
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
109 """Listen for changes in repositories."""
110
112 """Called after a changeset has been added to a repository."""
113
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
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
132
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
150
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
172
178
182
186
192
193 - def _do_add(self, reponame, dir, type_=None):
195
198
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
218
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
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
275
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
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
357 self._cache = {}
358 self._lock = threading.Lock()
359 self._connectors = None
360 self._all_repositories = None
361
362
363
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):
402
403
404
409
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
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
456
479
480
481
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
491 for option in repositories:
492 if option.endswith('.dir') and repositories.get(option):
493 reponames[option[:-4]] = {'sync_per_request': False}
494
495 for option in repositories:
496 alias = repositories.get(option)
497 if '.' not in option:
498 option += '.alias'
499 if option.endswith('.alias') and alias in reponames:
500 reponames.setdefault(option[:-6], {})['alias'] = alias
501
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
512
515
517 from pkg_resources import resource_filename
518 return [resource_filename('trac.versioncontrol', 'templates')]
519
520
521
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
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
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
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
592 with self.env.db_transaction:
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
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
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
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
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
672 else:
673 if repos is not None:
674 repositories.add(repos)
675 return sorted(repositories, key=lambda r: r.reponame)
676
678 """Reload the repositories from the providers."""
679 with self._lock:
680
681 self._cache = {}
682 self._all_repositories = None
683 self.config.touch()
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
695
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
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
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
788
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
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:
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
823 ResourceNotFound.__init__(self,
824 _('No changeset %(rev)s in the repository',
825 rev=rev),
826 _('No such changeset'))
827
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
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
853
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
875 return '<%s %r %r %r>' % (self.__class__.__name__,
876 self.id, self.name, self.scope)
877
878 @abstractmethod
880 """Close the connection to the repository."""
881 pass
882
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
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
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
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
940 """Retrieve a Changeset corresponding to the given revision `rev`."""
941 pass
942
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
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
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
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
987 """Return the oldest revision stored in the repository."""
988 pass
989 oldest_rev = property(lambda self: self.get_oldest_rev())
990
991 @abstractmethod
993 """Return the youngest revision in the repository."""
994 pass
995 youngest_rev = property(lambda self: self.get_youngest_rev())
996
997 @abstractmethod
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
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
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
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
1044 """Return a canonical representation of path in the repos."""
1045 pass
1046
1047 @abstractmethod
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
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
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
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
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
1119
1120
1121
1122
1123
1124
1125 created_rev = None
1126 created_path = None
1127
1128 - def __init__(self, repos, path, rev, kind):
1135
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
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
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
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
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
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
1241 return self.path.split('/')[-1]
1242 name = property(lambda self: self.get_name())
1243
1244 @abstractmethod
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
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
1258
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
1272 DIFF_CHANGES = (EDIT, COPY, MOVE)
1273 OTHER_CHANGES = (ADD, DELETE)
1274 ALL_CHANGES = DIFF_CHANGES + OTHER_CHANGES
1275
1276 realm = RepositoryManager.changeset_realm
1277
1278 @property
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
1290 name = u'%s@%s' % (self.repos.name, self.rev)
1291 return '<%s %r>' % (self.__class__.__name__, name)
1292
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
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
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
1333
1335 """Yield bookmarks associated with this changeset.
1336
1337 .. versionadded :: 1.1.5
1338 """
1339 return []
1340
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
1346
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):
1357
1360
1361
1362
1363
1364
1365
1366