source: tagsplugin/tags/0.7/tractags/api.py

Last change on this file was 13802, checked in by Steffen Hoffmann, 10 years ago

TagsPlugin: Log ITagProvider issue as warning, refs #11435 and #11658.

Since [13427] TagSystem.get_all_tags() requires stricter API conformance
of ITagProvider implementations regarding its get_tagged_resources method.
Own implementations have been fixed in [13461], but we keep the extra
'try-catch' from [13799-13800] for easier detection of the same issue with
other ITagProviders, namely versions of FullBlogPlugin before r13462.

File size: 20.7 KB
Line 
1# -*- coding: utf-8 -*-
2#
3# Copyright (C) 2006 Alec Thomas <alec@swapoff.org>
4# Copyright (C) 2014 Jun Omae <jun66j5@gmail.com>
5# Copyright (C) 2011-2014 Steffen Hoffmann <hoff.st@web.de>
6#
7# This software is licensed as described in the file COPYING, which
8# you should have received as part of this distribution.
9#
10
11import re
12try:
13    import threading
14except ImportError:
15    import dummy_threading as threading
16    threading._get_ident = lambda: 0
17
18from operator import itemgetter
19from pkg_resources import resource_filename
20
21from trac.config import BoolOption, ListOption
22from trac.core import Component, ExtensionPoint, Interface, TracError, \
23                      implements
24from trac.perm import IPermissionRequestor, PermissionError
25from trac.resource import IResourceManager, get_resource_url, \
26                          get_resource_description
27from trac.util.text import to_unicode
28from trac.wiki.model import WikiPage
29
30# Import translation functions.
31# Fallbacks make Babel still optional and provide for Trac 0.11.
32try:
33    from trac.util.translation  import domain_functions
34    add_domain, _, N_, gettext, ngettext, tag_, tagn_ = \
35        domain_functions('tractags', ('add_domain', '_', 'N_', 'gettext',
36                                      'ngettext', 'tag_', 'tagn_'))
37    dgettext = None
38except ImportError:
39    from trac.util.translation  import gettext
40    _ = gettext
41    N_ = lambda text: text
42    def add_domain(a,b,c=None):
43        pass
44    def dgettext(domain, string, **kwargs):
45        return safefmt(string, kwargs)
46    def ngettext(singular, plural, num, **kwargs):
47        string = (plural, singular)[num == 1]
48        kwargs.setdefault('num', num)
49        return safefmt(string, kwargs)
50    def tag_(string, **kwargs):
51        return _tag_kwargs(string, kwargs)
52    def tagn_(singular, plural, num, **kwargs):
53        string = (plural, singular)[num == 1]
54        kwargs.setdefault('num', num)
55        return _tag_kwargs(string, kwargs)
56    def safefmt(string, kwargs):
57        if kwargs:
58            try:
59                return string % kwargs
60            except KeyError:
61                pass
62        return string
63    _param_re = re.compile(r"%\((\w+)\)(?:s|[\d]*d|\d*.?\d*[fg])")
64    def _tag_kwargs(trans, kwargs):
65        from genshi.builder import tag
66        trans_elts = _param_re.split(trans)
67        for i in xrange(1, len(trans_elts), 2):
68            trans_elts[i] = kwargs.get(trans_elts[i], '???')
69        return tag(*trans_elts)
70
71from tractags.model import resource_tags, tag_frequency, tag_resource
72from tractags.model import tagged_resources
73# Now call module importing i18n methods from here.
74from tractags.query import *
75
76
77class Counter(dict):
78    """Dict subclass for counting hashable objects.
79
80    Sometimes called a bag or multiset.  Elements are stored as dictionary
81    keys and their counts are stored as dictionary values.
82
83    >>> Counter('zyzygy')
84    Counter({'y': 3, 'z': 2, 'g': 1})
85
86    """
87
88    def __init__(self, iterable=None, **kwargs):
89        """Create a new, empty Counter object.
90
91        And if given, count elements from an input iterable.  Or, initialize
92        the count from another mapping of elements to their counts.
93
94        >>> c = Counter()                   # a new, empty counter
95        >>> c = Counter('gallahad')         # a new counter from an iterable
96        >>> c = Counter({'a': 4, 'b': 2})   # a new counter from a mapping
97        >>> c = Counter(a=4, b=2)           # a new counter from keyword args
98
99        """
100        self.update(iterable, **kwargs)
101
102    def most_common(self, n=None):
103        """List the n most common elements and their counts from the most
104        common to the least.  If n is None, then list all element counts.
105
106        >>> Counter('abracadabra').most_common(3)
107        [('a', 5), ('r', 2), ('b', 2)]
108        >>> Counter('abracadabra').most_common()
109        [('a', 5), ('r', 2), ('b', 2), ('c', 1), ('d', 1)]
110
111        """
112        if n is None:
113            return sorted(self.iteritems(), key=itemgetter(1), reverse=True)
114        # DEVEL: Use `heapq.nlargest(n, self.iteritems(), key=itemgetter(1))`,
115        #        when compatibility with Python2.4 is not an issue anymore.
116        return sorted(self.iteritems(), key=itemgetter(1), reverse=True)[:n]
117
118    # Override dict methods where the meaning changes for Counter objects.
119
120    @classmethod
121    def fromkeys(cls, iterable, v=None):
122        raise NotImplementedError(
123            'Counter.fromkeys() is undefined. Use Counter(iterable) instead.')
124
125    def update(self, iterable=None, **kwargs):
126        """Like dict.update() but add counts instead of replacing them.
127
128        Source can be an iterable, a dictionary, or another Counter instance.
129
130        >>> c = Counter('which')
131        >>> c.update('witch')           # add elements from another iterable
132        >>> d = Counter('watch')
133        >>> c.update(d)                 # add elements from another counter
134        >>> c['h']                      # four 'h' in which, witch, and watch
135        4
136
137        """
138        if iterable is not None:
139            if hasattr(iterable, 'iteritems'):
140                if self:
141                    self_get = self.get
142                    for elem, count in iterable.iteritems():
143                        self[elem] = self_get(elem, 0) + count
144                else:
145                    dict.update(self, iterable) # fast path for empty counter
146            else:
147                self_get = self.get
148                for elem in iterable:
149                    self[elem] = self_get(elem, 0) + 1
150        if kwargs:
151            self.update(kwargs)
152
153    def copy(self):
154        """Like dict.copy() but returns a Counter instance instead of a dict.
155        """
156        return Counter(self)
157
158    def __delitem__(self, elem):
159        """Like dict.__delitem__(), but does not raise KeyError for missing
160        values.
161        """
162        if elem in self:
163            dict.__delitem__(self, elem)
164
165    def __repr__(self):
166        if not self:
167            return '%s()' % self.__class__.__name__
168        items = ', '.join(map('%r: %r'.__mod__, self.most_common()))
169        return '%s({%s})' % (self.__class__.__name__, items)
170
171    # Multiset-style mathematical operations discussed in:
172    #       Knuth TAOCP Volume II section 4.6.3 exercise 19
173    #       and at http://en.wikipedia.org/wiki/Multiset
174
175    def __add__(self, other):
176        """Add counts from two counters.
177
178        >>> Counter('abbb') + Counter('bcc')
179        Counter({'b': 4, 'c': 2, 'a': 1})
180
181        """
182        if not isinstance(other, Counter):
183            return NotImplemented
184        result = Counter()
185        self_get = self.get
186        other_get = other.get
187        for elem in set(self) | set(other):
188            newcount = self_get(elem, 0) + other_get(elem, 0)
189            if newcount > 0:
190                result[elem] = newcount
191        return result
192
193
194class InvalidTagRealm(TracError):
195    pass
196
197
198class ITagProvider(Interface):
199    """The interface for Components providing per-realm tag storage and
200    manipulation methods.
201
202    Change comments and reparenting are supported since tags-0.7.
203    """
204    def get_taggable_realm():
205        """Return the realm this provider supports tags on."""
206
207    def get_tagged_resources(req, tags=None, filter=None):
208        """Return a sequence of resources and *all* their tags.
209
210        :param tags: If provided, return only those resources with the given
211                     tags.
212        :param filter: If provided, skip matching resources.
213
214        :rtype: Sequence of (resource, tags) tuples.
215        """
216
217    def get_all_tags(req, filter=None):
218        """Return all tags with numbers of occurance.
219
220        :param filter: If provided, skip matching resources.
221
222        :rtype: Counter object (dict sub-class: {tag_name: tag_frequency} ).
223
224        """
225
226    def get_resource_tags(req, resource, when=None):
227        """Get tags for a Resource object."""
228
229    def set_resource_tags(req, resource, tags, comment=u'', when=None):
230        """Set tags for a resource."""
231
232    def reparent_resource_tags(req, resource, old_id, comment=u''):
233        """Move tags, typically when renaming an existing resource."""
234
235    def remove_resource_tags(req, resource, comment=u''):
236        """Remove all tags from a resource."""
237
238    def describe_tagged_resource(req, resource):
239        """Return a one line description of the tagged resource."""
240
241
242class DefaultTagProvider(Component):
243    """An abstract base tag provider that stores tags in the database.
244
245    Use this if you need storage for your tags. Simply set the class variable
246    `realm` and optionally `check_permission()`.
247
248    See tractags.wiki.WikiTagProvider for an example.
249    """
250
251    implements(ITagProvider)
252
253    abstract = True
254
255    # Resource realm this provider manages tags for. Set this.
256    realm = None
257
258    revisable = False
259
260    def __init__(self):
261        # Do this once, because configuration lookups are costly.
262        cfg = self.env.config
263        self.revisable = self.realm in cfg.getlist('tags', 'revisable_realms')
264
265    # Public methods
266
267    def check_permission(self, perm, action):
268        """Delegate function for checking permissions.
269
270        Override to implement custom permissions. Defaults to TAGS_VIEW and
271        TAGS_MODIFY.
272        """
273        map = {'view': 'TAGS_VIEW', 'modify': 'TAGS_MODIFY'}
274        return map[action] in perm('tag')
275
276    # ITagProvider methods
277
278    def get_taggable_realm(self):
279        return self.realm
280
281    def get_tagged_resources(self, req, tags=None, filter=None):
282        if not self.check_permission(req.perm, 'view'):
283            return
284        return tagged_resources(self.env, self.check_permission, req.perm,
285                                self.realm, tags, filter)
286
287    def get_all_tags(self, req, filter=None):
288        all_tags = Counter()
289        for tag, count in tag_frequency(self.env, self.realm, filter):
290            all_tags[tag] = count
291        return all_tags
292
293    def get_resource_tags(self, req, resource, when=None):
294        assert resource.realm == self.realm
295        if not self.check_permission(req.perm(resource), 'view'):
296            return
297        return resource_tags(self.env, resource, when=when)
298
299    def set_resource_tags(self, req, resource, tags, comment=u'', when=None):
300        assert resource.realm == self.realm
301        if not self.check_permission(req.perm(resource), 'modify'):
302            raise PermissionError(resource=resource, env=self.env)
303        tag_resource(self.env, resource, author=req.authname, tags=tags,
304                     log=self.revisable, when=when)
305
306    def reparent_resource_tags(self, req, resource, old_id, comment=u''):
307        assert resource.realm == self.realm
308        if not self.check_permission(req.perm(resource), 'modify'):
309            raise PermissionError(resource=resource, env=self.env)
310        tag_resource(self.env, resource, old_id, req.authname,
311                     log=self.revisable)
312
313    def remove_resource_tags(self, req, resource, comment=u''):
314        assert resource.realm == self.realm
315        if not self.check_permission(req.perm(resource), 'modify'):
316            raise PermissionError(resource=resource, env=self.env)
317        tag_resource(self.env, resource, author=req.authname,
318                     log=self.revisable)
319
320    def describe_tagged_resource(self, req, resource):
321        return ''
322
323
324class TagSystem(Component):
325    """Tagging system for Trac."""
326
327    implements(IPermissionRequestor, IResourceManager)
328
329    tag_providers = ExtensionPoint(ITagProvider)
330
331    revisable = ListOption('tags', 'revisable_realms', 'wiki',
332        doc="Comma-separated list of realms requiring tag change history.")
333    wiki_page_link = BoolOption('tags', 'wiki_page_link', True,
334        doc="Link a tag to the wiki page with same name, if it exists.")
335
336    # Internal variables
337    _realm = re.compile('realm:(\w+)', re.U | re.I)
338    _realm_provider_map = None
339
340    def __init__(self):
341        # Bind the 'tractags' catalog to the specified locale directory.
342        locale_dir = resource_filename(__name__, 'locale')
343        add_domain(self.env.path, locale_dir)
344
345        self._populate_provider_map()
346
347    # Public methods
348
349    def query(self, req, query='', attribute_handlers=None):
350        """Return a sequence of (resource, tags) tuples matching a query.
351
352        Query syntax is described in tractags.query.
353
354        :param attribute_handlers: Register additional query attribute
355                                   handlers. See Query documentation for more
356                                   information.
357        """
358        def realm_handler(_, node, context):
359            return query.match(node, [context.realm])
360
361        all_attribute_handlers = {
362            'realm': realm_handler,
363        }
364        all_attribute_handlers.update(attribute_handlers or {})
365        query = Query(query, attribute_handlers=all_attribute_handlers)
366        providers = set()
367        for m in self._realm.finditer(query.as_string()):
368            realm = m.group(1)
369            providers.add(self._get_provider(realm))
370        if not providers:
371            providers = self.tag_providers
372
373        query_tags = set(query.terms())
374        for provider in providers:
375            self.env.log.debug('Querying ' + repr(provider))
376            for resource, tags in provider.get_tagged_resources(req,
377                                                          query_tags) or []:
378                if query(tags, context=resource):
379                    yield resource, tags
380
381    def get_all_tags(self, req, realms=[]):
382        """Return all tags for all supported realms or only specified ones.
383
384        Returns a Counter object (special dict) with tag name as key and tag
385        frequency as value.
386        """
387        all_tags = Counter()
388        all_realms = set([p.get_taggable_realm()
389                          for p in self.tag_providers])
390        if not realms or set(realms) == all_realms:
391            realms = all_realms
392        for provider in self.tag_providers:
393            if provider.get_taggable_realm() in realms:
394                try:
395                    all_tags += provider.get_all_tags(req)
396                except AttributeError:
397                    # Fallback for older providers.
398                    try:
399                        for resource, tags in \
400                            provider.get_tagged_resources(req):
401                                all_tags.update(tags)
402                    except TypeError:
403                        # Defense against loose ITagProvider implementations,
404                        # that might become obsolete in the future.
405                        self.env.log.warning('ITagProvider %r has outdated'
406                                             'get_tagged_resources() method' %
407                                             provider)
408        return all_tags
409
410    def get_tags(self, req, resource, when=None):
411        """Get tags for resource."""
412        return set(self._get_provider(resource.realm) \
413                   .get_resource_tags(req, resource, when=when))
414
415    def set_tags(self, req, resource, tags, comment=u'', when=None):
416        """Set tags on a resource.
417
418        Existing tags are replaced.
419        """
420        try:
421            return self._get_provider(resource.realm) \
422                   .set_resource_tags(req, resource, set(tags), comment, when)
423        except TypeError:
424            # Handle old style tag providers gracefully.
425            return self._get_provider(resource.realm) \
426                   .set_resource_tags(req, resource, set(tags))
427
428    def add_tags(self, req, resource, tags, comment=u''):
429        """Add to existing tags on a resource."""
430        tags = set(tags)
431        tags.update(self.get_tags(req, resource))
432        try:
433            self.set_tags(req, resource, tags, comment)
434        except TypeError:
435            # Handle old style tag providers gracefully.
436            self.set_tags(req, resource, tags)
437
438    def reparent_tags(self, req, resource, old_name, comment=u''):
439        """Move tags, typically when renaming an existing resource.
440
441        Tags can't be moved between different tag realms with intention.
442        """
443        provider = self._get_provider(resource.realm)
444        provider.reparent_resource_tags(req, resource, old_name, comment)
445
446    def replace_tag(self, req, old_tags, new_tag=None, comment=u'',
447                    allow_delete=False, filter=[]):
448        """Replace one or more tags in all resources it exists/they exist in.
449
450        Tagged resources may be filtered by realm and tag deletion is
451        optionally allowed for convenience as well.
452        """
453        # Provide list regardless of attribute type.
454        for provider in [p for p in self.tag_providers
455                         if not filter or p.get_taggable_realm() in filter]:
456            for resource, tags in \
457                    provider.get_tagged_resources(req, old_tags):
458                old_tags = set(old_tags)
459                if old_tags.issuperset(tags) and not new_tag:
460                    if allow_delete:
461                        self.delete_tags(req, resource, None, comment)
462                else:
463                    s_tags = set(tags)
464                    eff_tags = s_tags - old_tags
465                    if new_tag:
466                        eff_tags.add(new_tag)
467                    # Prevent to touch resources without effective change.
468                    if eff_tags != s_tags and (allow_delete or new_tag):
469                        self.set_tags(req, resource, eff_tags, comment)
470
471    def delete_tags(self, req, resource, tags=None, comment=u''):
472        """Delete tags on a resource.
473
474        If tags is None, remove all tags on the resource.
475        """
476        provider = self._get_provider(resource.realm)
477        if tags is None:
478            try:
479                provider.remove_resource_tags(req, resource, comment)
480            except TypeError:
481                 # Handle old style tag providers gracefully.
482                provider.remove_resource_tags(req, resource)
483        else:
484            current_tags = set(provider.get_resource_tags(req, resource))
485            current_tags.difference_update(tags)
486            try:
487                provider.set_resource_tags(req, resource, current_tags,
488                                           comment)
489            except TypeError:
490                 # Handle old style tag providers gracefully.
491                provider.set_resource_tags(req, resource, current_tags)
492
493    def describe_tagged_resource(self, req, resource):
494        """Return a short description of a taggable resource."""
495        provider = self._get_provider(resource.realm)
496        if hasattr(provider, 'describe_tagged_resource'):
497            return provider.describe_tagged_resource(req, resource)
498        else:
499            self.env.log.warning('ITagProvider %r does not implement '
500                                 'describe_tagged_resource()' % provider)
501            return ''
502   
503    # IPermissionRequestor method
504    def get_permission_actions(self):
505        action = ['TAGS_VIEW', 'TAGS_MODIFY']
506        actions = [action[0], (action[1], [action[0]]),
507                   ('TAGS_ADMIN', action)]
508        return actions
509
510    # IResourceManager methods
511
512    def get_resource_realms(self):
513        yield 'tag'
514
515    def get_resource_url(self, resource, href, form_realms=None, **kwargs):
516        if self.wiki_page_link:
517            page = WikiPage(self.env, resource.id)
518            if page.exists:
519                return get_resource_url(self.env, page.resource, href,
520                                        **kwargs)
521        return href.tags(unicode(resource.id), form_realms, **kwargs)
522
523    def get_resource_description(self, resource, format='default',
524                                 context=None, **kwargs):
525        if self.wiki_page_link:
526            page = WikiPage(self.env, resource.id)
527            if page.exists:
528                return get_resource_description(self.env, page.resource,
529                                                format, **kwargs)
530        rid = to_unicode(resource.id)
531        if format in ('compact', 'default'):
532            return rid
533        else:
534            return u'tag:%s' % rid
535
536    # Internal methods
537
538    def _populate_provider_map(self):
539        if self._realm_provider_map is None:
540            # Only use the map once it is fully initialized.
541            map = dict((provider.get_taggable_realm(), provider)
542                       for provider in self.tag_providers)
543            self._realm_provider_map = map
544
545    def _get_provider(self, realm):
546        try:
547            return self._realm_provider_map[realm]
548        except KeyError:
549            raise InvalidTagRealm(_("Tags are not supported on the '%s' realm")
550                                  % realm)
551
552
553class RequestsProxy(object):
554
555    def __init__(self):
556        self.current = threading.local()
557
558    def get(self):
559        try:
560            return self.current.req
561        except:
562            return None
563
564    def set(self, req):
565        self.current.req = req
566
567    def reset(self):
568        self.current.req = None
569
570
571requests = RequestsProxy()
Note: See TracBrowser for help on using the repository browser.