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

Last change on this file was 16093, checked in by Ryan J Ollos, 7 years ago

0.9dev: Workaround invalid HTML generation by adding newlines

Fixes #12056.

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