source: tagsplugin/branches/0.8-stable/tractags/api.py

Last change on this file was 14159, checked in by Steffen Hoffmann, 9 years ago

TagsPlugin: Finally fix TagSystem.describe_tagged_resource, refs #2749.

While correcting [14156] the original implementation from [3880] is now fully
reworked into a reliable resource description getter method, obsoleting other
fallbacks.

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