Edgewall Software

source: trunk/trac/versioncontrol/api.py

Last change on this file was 17657, checked in by Jun Omae, 8 months ago

1.5.4dev: update copyright year to 2023 (refs #13402)

[skip ci]

  • Property svn:eol-style set to native
File size: 50.7 KB
Line 
1# -*- coding: utf-8 -*-
2#
3# Copyright (C) 2005-2023 Edgewall Software
4# Copyright (C) 2005 Christopher Lenz <cmlenz@gmx.de>
5# All rights reserved.
6#
7# This software is licensed as described in the file COPYING, which
8# you should have received as part of this distribution. The terms
9# are also available at https://trac.edgewall.org/wiki/TracLicense.
10#
11# This software consists of voluntary contributions made by many
12# individuals. For the exact contribution history, see the revision
13# history and logs, available at https://trac.edgewall.org/log/.
14#
15# Author: Christopher Lenz <cmlenz@gmx.de>
16
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.items():
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 iter(reponames.items())
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.values()):
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.values()):
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.items():
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 "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.items():
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().items():
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.items():
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 `str` 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, metaclass=ABCMeta):
840 """Base class for a repository provided by a version control system."""
841
842 has_linear_changesets = False
843
844 scope = '/'
845
846 realm = RepositoryManager.repository_realm
847
848 @property
849 def resource(self):
850 return Resource(self.realm, self.reponame)
851
852 def __init__(self, name, params, log):
853 """Initialize a repository.
854
855 :param name: a unique name identifying the repository, usually a
856 type-specific prefix followed by the path to the
857 repository.
858 :param params: a `dict` of parameters for the repository. Contains
859 the name of the repository under the key "name" and
860 the surrogate key that identifies the repository in
861 the database under the key "id".
862 :param log: a logger instance.
863
864 :raises InvalidRepository: if the repository cannot be opened.
865 """
866 self.name = name
867 self.params = params
868 self.reponame = params['name']
869 self.id = params['id']
870 self.log = log
871
872 def __repr__(self):
873 return '<%s %r %r %r>' % (self.__class__.__name__,
874 self.id, self.name, self.scope)
875
876 @abstractmethod
877 def close(self):
878 """Close the connection to the repository."""
879 pass
880
881 def get_base(self):
882 """Return the name of the base repository for this repository.
883
884 This function returns the name of the base repository to which scoped
885 repositories belong. For non-scoped repositories, it returns the
886 repository name.
887 """
888 return self.name
889
890 def clear(self, youngest_rev=None):
891 """Clear any data that may have been cached in instance properties.
892
893 `youngest_rev` can be specified as a way to force the value
894 of the `youngest_rev` property (''will change in 0.12'').
895 """
896 pass
897
898 def sync(self, rev_callback=None, clean=False):
899 """Perform a sync of the repository cache, if relevant.
900
901 If given, `rev_callback` must be a callable taking a `rev` parameter.
902 The backend will call this function for each `rev` it decided to
903 synchronize, once the synchronization changes are committed to the
904 cache. When `clean` is `True`, the cache is cleaned first.
905 """
906 pass
907
908 def sync_changeset(self, rev):
909 """Resync the repository cache for the given `rev`, if relevant.
910
911 Returns a "metadata-only" changeset containing the metadata prior to
912 the resync, or `None` if the old values cannot be retrieved (typically
913 when the repository is not cached).
914 """
915 return None
916
917 def get_quickjump_entries(self, rev):
918 """Generate a list of interesting places in the repository.
919
920 `rev` might be used to restrict the list of available locations,
921 but in general it's best to produce all known locations.
922
923 The generated results must be of the form (category, name, path, rev).
924 """
925 return []
926
927 def get_path_url(self, path, rev):
928 """Return the repository URL for the given path and revision.
929
930 The returned URL can be `None`, meaning that no URL has been specified
931 for the repository, an absolute URL, or a scheme-relative URL starting
932 with `//`, in which case the scheme of the request should be prepended.
933 """
934 return None
935
936 @abstractmethod
937 def get_changeset(self, rev):
938 """Retrieve a Changeset corresponding to the given revision `rev`."""
939 pass
940
941 def get_changeset_uid(self, rev):
942 """Return a globally unique identifier for the ''rev'' changeset.
943
944 Two changesets from different repositories can sometimes refer to
945 the ''very same'' changeset (e.g. the repositories are clones).
946 """
947
948 def get_changesets(self, start, stop):
949 """Generate Changeset belonging to the given time period (start, stop).
950 """
951 rev = self.youngest_rev
952 while rev:
953 chgset = self.get_changeset(rev)
954 if chgset.date < start:
955 return
956 if chgset.date < stop:
957 yield chgset
958 rev = self.previous_rev(rev)
959
960 def has_node(self, path, rev=None):
961 """Tell if there's a node at the specified (path,rev) combination.
962
963 When `rev` is `None`, the latest revision is implied.
964 """
965 try:
966 self.get_node(path, rev)
967 return True
968 except TracError:
969 return False
970
971 @abstractmethod
972 def get_node(self, path, rev=None):
973 """Retrieve a Node from the repository at the given path.
974
975 A Node represents a directory or a file at a given revision in the
976 repository.
977 If the `rev` parameter is specified, the Node corresponding to that
978 revision is returned, otherwise the Node corresponding to the youngest
979 revision is returned.
980 """
981 pass
982
983 @abstractmethod
984 def get_oldest_rev(self):
985 """Return the oldest revision stored in the repository."""
986 pass
987 oldest_rev = property(lambda self: self.get_oldest_rev())
988
989 @abstractmethod
990 def get_youngest_rev(self):
991 """Return the youngest revision in the repository."""
992 pass
993 youngest_rev = property(lambda self: self.get_youngest_rev())
994
995 @abstractmethod
996 def previous_rev(self, rev, path=''):
997 """Return the revision immediately preceding the specified revision.
998
999 If `path` is given, filter out ancestor revisions having no changes
1000 below `path`.
1001
1002 In presence of multiple parents, this follows the first parent.
1003 """
1004 pass
1005
1006 @abstractmethod
1007 def next_rev(self, rev, path=''):
1008 """Return the revision immediately following the specified revision.
1009
1010 If `path` is given, filter out descendant revisions having no changes
1011 below `path`.
1012
1013 In presence of multiple children, this follows the first child.
1014 """
1015 pass
1016
1017 def parent_revs(self, rev):
1018 """Return a list of parents of the specified revision."""
1019 parent = self.previous_rev(rev)
1020 return [parent] if parent is not None else []
1021
1022 @abstractmethod
1023 def rev_older_than(self, rev1, rev2):
1024 """Provides a total order over revisions.
1025
1026 Return `True` if `rev1` is an ancestor of `rev2`.
1027 """
1028 pass
1029
1030 @abstractmethod
1031 def get_path_history(self, path, rev=None, limit=None):
1032 """Retrieve all the revisions containing this path.
1033
1034 If given, `rev` is used as a starting point (i.e. no revision
1035 ''newer'' than `rev` should be returned).
1036 The result format should be the same as the one of Node.get_history()
1037 """
1038 pass
1039
1040 @abstractmethod
1041 def normalize_path(self, path):
1042 """Return a canonical representation of path in the repos."""
1043 pass
1044
1045 @abstractmethod
1046 def normalize_rev(self, rev):
1047 """Return a (unique) canonical representation of a revision.
1048
1049 It's up to the backend to decide which string values of `rev`
1050 (usually provided by the user) should be accepted, and how they
1051 should be normalized. Some backends may for instance want to match
1052 against known tags or branch names.
1053
1054 In addition, if `rev` is `None` or '', the youngest revision should
1055 be returned.
1056
1057 :raise NoSuchChangeset: If the given `rev` isn't found.
1058 """
1059 pass
1060
1061 def short_rev(self, rev):
1062 """Return a compact string representation of a revision in the
1063 repos.
1064
1065 :raise NoSuchChangeset: If the given `rev` isn't found.
1066 :since 1.2: Always returns a string or `None`.
1067 """
1068 norm_rev = self.normalize_rev(rev)
1069 return str(norm_rev) if norm_rev is not None else norm_rev
1070
1071 def display_rev(self, rev):
1072 """Return a string representation of a revision in the repos for
1073 displaying to the user.
1074
1075 This can be a shortened revision string, e.g. for repositories
1076 using long hashes.
1077
1078 :raise NoSuchChangeset: If the given `rev` isn't found.
1079 :since 1.2: Always returns a string or `None`.
1080 """
1081 norm_rev = self.normalize_rev(rev)
1082 return str(norm_rev) if norm_rev is not None else norm_rev
1083
1084 @abstractmethod
1085 def get_changes(self, old_path, old_rev, new_path, new_rev,
1086 ignore_ancestry=1):
1087 """Generates changes corresponding to generalized diffs.
1088
1089 Generator that yields change tuples (old_node, new_node, kind, change)
1090 for each node change between the two arbitrary (path,rev) pairs.
1091
1092 The old_node is assumed to be None when the change is an ADD,
1093 the new_node is assumed to be None when the change is a DELETE.
1094 """
1095 pass
1096
1097 def is_viewable(self, perm):
1098 """Return True if view permission is granted on the repository."""
1099 return 'BROWSER_VIEW' in perm(self.resource.child('source', '/'))
1100
1101 can_view = is_viewable # 0.12 compatibility
1102
1103
1104class Node(object, metaclass=ABCMeta):
1105 """Represents a directory or file in the repository at a given revision."""
1106
1107 DIRECTORY = "dir"
1108 FILE = "file"
1109
1110 realm = RepositoryManager.source_realm
1111
1112 @property
1113 def resource(self):
1114 return Resource(self.realm, self.path, self.rev, self.repos.resource)
1115
1116 # created_path and created_rev properties refer to the Node "creation"
1117 # in the Subversion meaning of a Node in a versioned tree (see #3340).
1118 #
1119 # Those properties must be set by subclasses.
1120 #
1121 created_rev = None
1122 created_path = None
1123
1124 def __init__(self, repos, path, rev, kind):
1125 assert kind in (Node.DIRECTORY, Node.FILE), \
1126 "Unknown node kind %s" % kind
1127 self.repos = repos
1128 self.path = to_unicode(path)
1129 self.rev = rev
1130 self.kind = kind
1131
1132 def __repr__(self):
1133 name = '%s:%s' % (self.repos.name, self.path)
1134 if self.rev is not None:
1135 name += '@' + str(self.rev)
1136 return '<%s %r>' % (self.__class__.__name__, name)
1137
1138 @abstractmethod
1139 def get_content(self):
1140 """Return a stream for reading the content of the node.
1141
1142 This method will return `None` for directories.
1143 The returned object must support a `read([len])` method.
1144 """
1145 pass
1146
1147 def get_processed_content(self, keyword_substitution=True, eol_hint=None):
1148 """Return a stream for reading the content of the node, with some
1149 standard processing applied.
1150
1151 :param keyword_substitution: if `True`, meta-data keywords
1152 present in the content like ``$Rev$`` are substituted
1153 (which keyword are substituted and how they are
1154 substituted is backend specific)
1155
1156 :param eol_hint: which style of line ending is expected if
1157 `None` was explicitly specified for the file itself in
1158 the version control backend (for example in Subversion,
1159 if it was set to ``'native'``). It can be `None`,
1160 ``'LF'``, ``'CR'`` or ``'CRLF'``.
1161 """
1162 return self.get_content()
1163
1164 @abstractmethod
1165 def get_entries(self):
1166 """Generator that yields the immediate child entries of a directory.
1167
1168 The entries are returned in no particular order.
1169 If the node is a file, this method returns `None`.
1170 """
1171 pass
1172
1173 @abstractmethod
1174 def get_history(self, limit=None):
1175 """Provide backward history for this Node.
1176
1177 Generator that yields `(path, rev, chg)` tuples, one for each revision
1178 in which the node was changed. This generator will follow copies and
1179 moves of a node (if the underlying version control system supports
1180 that), which will be indicated by the first element of the tuple
1181 (i.e. the path) changing.
1182 Starts with an entry for the current revision.
1183
1184 :param limit: if given, yield at most ``limit`` results.
1185 """
1186 pass
1187
1188 def get_previous(self):
1189 """Return the change event corresponding to the previous revision.
1190
1191 This returns a `(path, rev, chg)` tuple.
1192 """
1193 skip = True
1194 for p in self.get_history(2):
1195 if skip:
1196 skip = False
1197 else:
1198 return p
1199
1200 @abstractmethod
1201 def get_annotations(self):
1202 """Provide detailed backward history for the content of this Node.
1203
1204 Retrieve an array of revisions, one `rev` for each line of content
1205 for that node.
1206 Only expected to work on (text) FILE nodes, of course.
1207 """
1208 pass
1209
1210 @abstractmethod
1211 def get_properties(self):
1212 """Returns the properties (meta-data) of the node, as a dictionary.
1213
1214 The set of properties depends on the version control system.
1215 """
1216 pass
1217
1218 @abstractmethod
1219 def get_content_length(self):
1220 """The length in bytes of the content.
1221
1222 Will be `None` for a directory.
1223 """
1224 pass
1225 content_length = property(lambda self: self.get_content_length())
1226
1227 @abstractmethod
1228 def get_content_type(self):
1229 """The MIME type corresponding to the content, if known.
1230
1231 Will be `None` for a directory.
1232 """
1233 pass
1234 content_type = property(lambda self: self.get_content_type())
1235
1236 def get_name(self):
1237 return self.path.split('/')[-1]
1238 name = property(lambda self: self.get_name())
1239
1240 @abstractmethod
1241 def get_last_modified(self):
1242 pass
1243 last_modified = property(lambda self: self.get_last_modified())
1244
1245 isdir = property(lambda self: self.kind == Node.DIRECTORY)
1246 isfile = property(lambda self: self.kind == Node.FILE)
1247
1248 def is_viewable(self, perm):
1249 """Return True if view permission is granted on the node."""
1250 return ('BROWSER_VIEW' if self.isdir else 'FILE_VIEW') \
1251 in perm(self.resource)
1252
1253 can_view = is_viewable # 0.12 compatibility
1254
1255
1256class Changeset(object, metaclass=ABCMeta):
1257 """Represents a set of changes committed at once in a repository."""
1258
1259 ADD = 'add'
1260 COPY = 'copy'
1261 DELETE = 'delete'
1262 EDIT = 'edit'
1263 MOVE = 'move'
1264
1265 # change types which can have diff associated to them
1266 DIFF_CHANGES = (EDIT, COPY, MOVE) # MERGE
1267 OTHER_CHANGES = (ADD, DELETE)
1268 ALL_CHANGES = DIFF_CHANGES + OTHER_CHANGES
1269
1270 realm = RepositoryManager.changeset_realm
1271
1272 @property
1273 def resource(self):
1274 return Resource(self.realm, self.rev, parent=self.repos.resource)
1275
1276 def __init__(self, repos, rev, message, author, date):
1277 self.repos = repos
1278 self.rev = rev
1279 self.message = message or ''
1280 self.author = author or ''
1281 self.date = date
1282
1283 def __repr__(self):
1284 name = '%s@%s' % (self.repos.name, self.rev)
1285 return '<%s %r>' % (self.__class__.__name__, name)
1286
1287 def get_properties(self):
1288 """Returns the properties (meta-data) of the node, as a dictionary.
1289
1290 The set of properties depends on the version control system.
1291
1292 Warning: this used to yield 4-elements tuple (besides `name` and
1293 `text`, there were `wikiflag` and `htmlclass` values).
1294 This is now replaced by the usage of IPropertyRenderer (see #1601).
1295 """
1296 return []
1297
1298 @abstractmethod
1299 def get_changes(self):
1300 """Generator that produces a tuple for every change in the changeset.
1301
1302 The tuple will contain `(path, kind, change, base_path, base_rev)`,
1303 where `change` can be one of Changeset.ADD, Changeset.COPY,
1304 Changeset.DELETE, Changeset.EDIT or Changeset.MOVE,
1305 and `kind` is one of Node.FILE or Node.DIRECTORY.
1306 The `path` is the targeted path for the `change` (which is
1307 the ''deleted'' path for a DELETE change).
1308 The `base_path` and `base_rev` are the source path and rev for the
1309 action (`None` and `-1` in the case of an ADD change).
1310 """
1311 pass
1312
1313 def get_branches(self):
1314 """Yield branches to which this changeset belong.
1315 Each branch is given as a pair `(name, head)`, where `name` is
1316 the branch name and `head` a flag set if the changeset is a head
1317 for this branch (i.e. if it has no children changeset).
1318 """
1319 return []
1320
1321 def get_tags(self):
1322 """Yield tags associated with this changeset.
1323
1324 .. versionadded :: 1.0
1325 """
1326 return []
1327
1328 def get_bookmarks(self):
1329 """Yield bookmarks associated with this changeset.
1330
1331 .. versionadded :: 1.1.5
1332 """
1333 return []
1334
1335 def is_viewable(self, perm):
1336 """Return True if view permission is granted on the changeset."""
1337 return 'CHANGESET_VIEW' in perm(self.resource)
1338
1339 can_view = is_viewable # 0.12 compatibility
1340
1341
1342class EmptyChangeset(Changeset):
1343 """Changeset that contains no changes. This is typically used when the
1344 changeset can't be retrieved."""
1345
1346 def __init__(self, repos, rev, message=None, author=None, date=None):
1347 if date is None:
1348 date = datetime(1970, 1, 1, tzinfo=utc)
1349 super().__init__(repos, rev, message, author, date)
1350
1351 def get_changes(self):
1352 return iter([])
1353
1354
1355# Note: Since Trac 0.12, Exception PermissionDenied class is gone,
1356# and class Authorizer is gone as well.
1357#
1358# Fine-grained permissions are now handled via normal permission policies.
Note: See TracBrowser for help on using the repository browser.