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

Last change on this file was 1830, checked in by Alec Thomas, 17 years ago
  • Ditched non-expression searching altogether.
  • Workaround for a bug in Trac that prevented quoted expression components from working.
  • Added tagged: link resolver which will deprecate tag: in a future release.
  • Fixed a bug in the template where users without WIKI_MODIFY permissions could not view Wiki tags.
File size: 15.8 KB
Line 
1"""
2
3Implementation of a generic tagging API for Trac. The API lets plugins register
4use of a set of namespaces (tagspaces) then access and manipulate the tags in
5that tagspace.
6
7For integration of external programs, the API also allows other tag systems to
8be accessed transparently (see the ITaggingSystemProvider interface and the
9corresponding TaggingSystem class).
10
11Taggable names are contained in a tagspace and can be associated with any
12number of tags. eg. ('wiki', 'WikiStart', 'start') represents a 'start' tag to
13the 'WikiStart' page in the 'wiki' tagspace.
14
15For a component to register a new tagspace for use it must implement the
16ITagSpaceUser interface.
17
18To access tags for a tagspace use the following mechanism (using the 'wiki'
19tagspace in this example):
20
21{{{
22#!python
23from tractags.api import TagEngine
24
25tags = TagEngine(env).tagspace.wiki
26# Display all names and the tags associated with each name
27for name in tags.get_tagged_names():
28    print name, list(tags.get_name_tags(name))
29# Display all tags and the names associated with each tag
30for tag in tags.get_tags():
31    print tag, list(tags.get_tagged_names(tag))
32# Add a start tag to WikiStart
33tags.add_tags(None, 'WikiStart', ['start'])
34}}}
35
36"""
37
38from trac.core import *
39from trac.env import IEnvironmentSetupParticipant
40from trac.db import Table, Column, Index
41import sys
42import re
43
44try:
45    set = set
46except:
47    from sets import Set as set
48
49try:
50    sorted = sorted
51except NameError:
52    def sorted(iterable):
53        lst = list(iterable)
54        lst.sort()
55        return lst
56
57class ITagSpaceUser(Interface):
58    """ Register that this component uses a set of tagspaces. If a tagspace is
59        not registered, it can not be used. """
60    def tagspaces_used():
61        """ Return an iterator of tagspaces used by this plugin. """
62
63class ITaggingSystemProvider(Interface):
64    """ An implementation of a tag system. This allows other non-Trac-native
65        tag systems to be accessed through one API. """
66
67    def get_tagspaces_provided():
68        """ Iterable of tagspaces provided by this tag system. """
69
70    def get_tagging_system(tagspace):
71        """ Return the TaggingSystem responsible for tagspace. """
72
73class TaggingSystem(object):
74    """ An implementation of a tagging system. """
75    def __init__(self, env, tagspace):
76        self.env = env
77        self.tagspace = tagspace
78
79    def walk_tagged_names(self, names, tags, predicate):
80        """ Generator returning a tuple of (name, tags) for each tagged name
81            in this tagspace that meets the predicate and is in the set
82            of names and has any of the given tags.
83
84            predicate is called with (name, tags)
85
86            (The names and tags arguments are purely an optimisation
87            opportunity for the underlying TaggingSystem)
88            """
89
90    def get_name_tags(self, name):
91        """ Get tags for a name. """
92        raise NotImplementedError
93
94    def add_tags(self, req, name, tags):
95        """ Tag name in tagspace with tags. """
96        raise NotImplementedError
97
98    def replace_tags(self, req, name, tags):
99        """ Replace existing tags on name with tags. """
100        self.remove_all_tags(req, name)
101        self.add_tags(req, name, tags)
102
103    def remove_tags(self, req, name, tags):
104        """ Remove tags from a name in a tagspace. """
105        raise NotImplementedError
106
107    def remove_all_tags(self, req, name):
108        """ Remove all tags from a name in a tagspace. """
109        self.remove_tags(req, name, self.get_name_tags(name))
110
111    def name_details(self, name):
112        """ Return a tuple of (href, htmllink, title). eg.
113            ("/ticket/1", "<a href="/ticket/1">#1</a>", "Broken links") """
114        raise NotImplementedError
115
116class DefaultTaggingSystem(TaggingSystem):
117    """ Default tagging system. Handles any number of namespaces registered via
118        ITagSpaceUser. """
119
120    def walk_tagged_names(self, names, tags, predicate):
121        db = self.env.get_db_cnx()
122        cursor = db.cursor()
123
124        args = [self.tagspace]
125        sql = 'SELECT DISTINCT name, tag FROM tags WHERE tagspace=%s'
126        if names:
127            sql += ' AND name IN (' + ', '.join(['%s' for n in names]) + ')'
128            args += names
129        if tags:
130            sql += ' AND name in (SELECT name FROM tags WHERE tag in (' + ', '.join(['%s' for t in tags]) + '))'
131            args += tags
132        sql += " ORDER BY name"
133        cursor.execute(sql, args)
134
135        tags = set(tags)
136        current_name = None
137        name_tags = set()
138        for name, tag in cursor:
139            if current_name != name:
140                if current_name is not None:
141                    if predicate(current_name, name_tags):
142                        yield (current_name, name_tags)
143                name_tags = set([tag])
144                current_name = name
145            else:
146                name_tags.add(tag)
147        if current_name is not None and predicate(current_name, name_tags):
148            yield (current_name, name_tags)
149
150    def get_name_tags(self, name):
151        db = self.env.get_db_cnx()
152        cursor = db.cursor()
153        cursor.execute('SELECT tag FROM tags WHERE tagspace=%s AND name=%s', (self.tagspace, name))
154        return set([row[0] for row in cursor])
155
156    def add_tags(self, req, name, tags):
157        db = self.env.get_db_cnx()
158        cursor = db.cursor()
159        for tag in tags:
160            cursor.execute('INSERT INTO tags (tagspace, name, tag) VALUES (%s, %s, %s)', (self.tagspace, name, tag))
161        db.commit()
162
163    def remove_tags(self, req, name, tags):
164        db = self.env.get_db_cnx()
165        cursor = db.cursor()
166        sql = "DELETE FROM tags WHERE tagspace = %s AND name = %s AND tag " \
167              "IN (" + ', '.join(["%s" for t in tags]) + ")"
168        cursor.execute(sql, (self.tagspace, name) + tuple(tags))
169        db.commit()
170
171    def remove_all_tags(self, req, name):
172        db = self.env.get_db_cnx()
173        cursor = db.cursor()
174        cursor.execute('DELETE FROM tags WHERE tagspace=%s AND name=%s', (self.tagspace, name))
175        db.commit()
176       
177    def name_details(self, name):
178        from trac.wiki.formatter import wiki_to_oneliner
179        return (getattr(self.env.href, self.tagspace),
180                wiki_to_oneliner('[%s:"%s" %s]' % (self.tagspace, name, name), self.env), '')
181
182class TagspaceProxy:
183    """ A convenience for performing operations on a specific tagspace,
184        including get_tags() and get_tagged_names(). Both of these functions
185        will only search that tagspace, and will return values stripped of
186        tagspace information. """
187    def __init__(self, engine, tagspace):
188        self.engine = engine
189        self.tagspace = tagspace
190        self.tagsystem = engine._get_tagsystem(tagspace)
191
192    def get_tags(self, *args, **kwargs):
193        result = self.engine.get_tags(tagspaces=[self.tagspace], *args, **kwargs)
194        if isinstance(result, set):
195            return result
196        else:
197            out = {}
198            for tag, names in result.iteritems():
199                out[tag] = set([name for _, name in names])
200            return out
201
202    def get_tagged_names(self, *args, **kwargs):
203        return self.engine.get_tagged_names(tagspaces=[self.tagspace], *args, **kwargs)[self.tagspace]
204
205    def __getattr__(self, attr):
206        return getattr(self.tagsystem, attr)
207
208class TagspaceDirector(object):
209    """ A convenience similar to env.href, proxying to the correct TagSystem by
210        attribute. """
211    def __init__(self, engine):
212        self.engine = engine
213
214    def __getattr__(self, tagspace):
215        return self.tagspace(tagspace)
216
217    def tagspace(self, tagspace):
218        return TagspaceProxy(self.engine, tagspace)
219
220class TagEngine(Component):
221    """ The core of the Trac tag API. This interface can be used to register
222        tagspaces (ITagSpaceUser or register_tagspace()), add other tagging
223        systems (ITaggingSystemProvider), and to control tags in a tagspace.
224    """
225
226    _tagspace_re = re.compile(r'''^[a-zA-Z_][a-zA-Z0-9_]*$''')
227
228    implements(ITaggingSystemProvider, IEnvironmentSetupParticipant)
229
230    tag_users = ExtensionPoint(ITagSpaceUser)
231    tagging_systems = ExtensionPoint(ITaggingSystemProvider)
232
233    SCHEMA = [
234        Table('tags', key = ('tagspace', 'name', 'tag'))[
235              Column('tagspace'),
236              Column('name'),
237              Column('tag'),
238              Index(['tagspace', 'name']),
239              Index(['tagspace', 'tag']),]
240        ]
241
242    def __init__(self):
243        self.tagspace = TagspaceDirector(self)
244        self._tagsystem_cache = {}
245        self._tag_link_cache = {}
246
247    def _get_tagspaces(self):
248        """ Get iterable of available tagspaces. """
249        out = []
250        for tagsystem in self.tagging_systems:
251            for tagspace in tagsystem.get_tagspaces_provided():
252                out.append(tagspace)
253        return out
254    tagspaces = property(_get_tagspaces)
255
256    def _get_tagsystem(self, tagspace):
257        """ Returns a TaggingSystem proxy object with tagspace as the default
258            tagspace. """
259        try:
260            return self._tagsystem_cache[tagspace]
261        except KeyError:
262            for tagsystem in self.tagging_systems:
263                if tagspace in tagsystem.get_tagspaces_provided():
264                   self._tagsystem_cache[tagspace] = tagsystem.get_tagging_system(tagspace)
265                   return self._tagsystem_cache[tagspace]
266        raise TracError("No such tagspace '%s'" % tagspace)
267
268    # Public methods
269    def flush_link_cache(self, tag=None):
270        """ Flush the link cache entirely, or for a single tag. """
271        if not tag:
272            self._tag_link_cache = {}
273        elif tag in self._tag_link_cache:
274            del self._tag_link_cache[tag]
275
276    def walk_tagged_names(self, names=[], tags=[], tagspaces=[], predicate=lambda tagspace, name, tags: True):
277        """ Generator returning (tagspace, name, tags) for all names in the
278            given tagspaces. Objects must have at least one of tags, be in
279            names and must meet the predicate. """
280        tagspaces = tagspaces or self.tagspaces
281        for tagspace in tagspaces:
282            tagsystem = self._get_tagsystem(tagspace)
283            for name, name_tags in tagsystem.walk_tagged_names(names=names, tags=tags, predicate=lambda n, t: predicate(tagspace, n, t)):
284                yield (tagspace, name, name_tags)
285
286    def get_tags(self, names=[], tagspaces=[], operation='union', detailed=False):
287        """ Get tags with the given names from the given tagspaces.
288            'operation' is the union or intersection of all tags on
289            names. If detailed, return a set of
290            {tag:set([(tagspace, name), ...])}, otherwise return a set of
291            tags. """
292        assert type(names) in (list, tuple, set)
293        tagspaces = tagspaces or self.tagspaces
294        seed_set = True
295        all_tags = set()
296        tagged_names = {}
297        for tagspace, name, tags in self.walk_tagged_names(names=names, tagspaces=tagspaces):
298            for tag in tags:
299                tagged_names.setdefault(tag, set()).add((tagspace, name))
300            if operation == 'intersection':
301                if seed_set:
302                    seed_set = False
303                    all_tags.update(tags)
304                else:
305                    all_tags.intersection_update(tags)
306                    if not all_tags:
307                        return detailed and {} or set()
308            else:
309                all_tags.update(tags)
310        if detailed:
311            out_tags = {}
312            for tag in all_tags:
313                out_tags[tag] = tagged_names[tag]
314            return out_tags
315        else:
316            return all_tags
317
318    def get_tagged_names(self, tags=[], tagspaces=[], operation='intersection', detailed=False):
319        """ Get names with the given tags from tagspaces. 'operation' is the set
320            operatin to perform on the sets of names tagged with each of the
321            search tags, and can be either 'intersection' or 'union'.
322
323            If detailed=True return a dictionary of
324            {tagspace:{name:set([tag, ...])}} otherwise return a dictionary of
325            {tagspace:set([name, ...])}. """
326        assert type(tags) in (list, tuple, set)
327        tagspaces = tagspaces or self.tagspaces
328        tags = set(tags)
329        if detailed:
330            output = dict([(ts, {}) for ts in tagspaces])
331        else:
332            output = dict([(ts, set()) for ts in tagspaces])
333        for tagspace, name, name_tags in self.walk_tagged_names(tags=tags, tagspaces=tagspaces):
334            if operation == 'intersection' and tags.intersection(name_tags) != tags:
335                continue
336            if detailed:
337                output[tagspace][name] = name_tags
338            else:
339                output[tagspace].add(name)
340        return output
341
342    def get_tag_link(self, tag, is_expression=False):
343        """ Return (href, title) to information about tag. This first checks for
344            a Wiki page named <tag>, then uses /tags/<tag>. If is_expression=True, treat
345            the tag as an expression rather than a simple tag. """
346        if tag in self._tag_link_cache:
347            return self._tag_link_cache[tag]
348        from tractags.wiki import WikiTaggingSystem
349        page, title = WikiTaggingSystem(self.env).page_info(tag)
350        if page.exists:
351            result = (self.env.href.wiki(tag), title)
352        else:
353            tag_link = is_expression and tag or "'%s'" % tag
354            result = (self.env.href.tags(tag_link), "Objects tagged ''%s''" % tag)
355        self._tag_link_cache[tag] = result
356        return result
357   
358
359    def name_details(self, tagspace, name):
360        """ Return a tuple of (href, htmllink, title). eg.
361            ("/ticket/1", "<a href="/ticket/1">#1</a>", "Broken links") """
362        return self._get_tagsystem(tagspace).name_details(name)
363
364    # ITaggingSystemProvider methods
365    def get_tagspaces_provided(self):
366        for user in self.tag_users:
367            for tagspace in user.tagspaces_used():
368                yield tagspace
369
370    def get_tagging_system(self, tagspace):
371        for taguser in self.tag_users:
372            if tagspace in taguser.tagspaces_used():
373                return DefaultTaggingSystem(self.env, tagspace)
374        raise TracError("No such tagspace '%s'" % tagspace)
375
376    # IEnvironmentSetupParticipant methods
377    def environment_created(self):
378        self._upgrade_db(self.env.get_db_cnx())
379
380    def environment_needs_upgrade(self, db):
381        cursor = db.cursor()
382        if self._need_migration(db):
383            return True
384        try:
385            cursor.execute("select count(*) from tags")
386            cursor.fetchone()
387            return False
388        except:
389            db.rollback()
390            return True
391
392    def upgrade_environment(self, db):
393        self._upgrade_db(db)
394
395    def _need_migration(self, db):
396        cursor = db.cursor()
397        try:
398            cursor.execute("select count(*) from wiki_namespace")
399            cursor.fetchone()
400            self.env.log.debug("tractags needs to migrate old data")
401            return True
402        except:
403            db.rollback()
404            return False
405
406    def _upgrade_db(self, db):
407        try:
408            try:
409                from trac.db import DatabaseManager
410                db_backend, _ = DatabaseManager(self.env)._get_connector()
411            except ImportError:
412                db_backend = self.env.get_db_cnx()
413
414            cursor = db.cursor()
415            for table in self.SCHEMA:
416                for stmt in db_backend.to_sql(table):
417                    self.env.log.debug(stmt)
418                    cursor.execute(stmt)
419            db.commit()
420
421            # Migrate old data
422            if self._need_migration(db):
423                cursor = db.cursor()
424                cursor.execute("INSERT INTO tags (tagspace, name, tag) SELECT 'wiki', name, namespace FROM wiki_namespace")
425                cursor.execute("DROP TABLE wiki_namespace")
426                db.commit()
427        except Exception, e:
428            db.rollback()
429            self.env.log.error(e, exc_info=1)
430            raise TracError(str(e))
Note: See TracBrowser for help on using the repository browser.